diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2255c103..7c18f034 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed editor paste registry corruption when deleting paste markers: undo now restores the paste registry together with the text, and marker renumbering shifts registry entries in ascending id order, so submitted prompts no longer contain literal `[paste #N ...]` markers or the wrong paste's content ([#6844](https://github.com/earendil-works/pi/issues/6844)). + ## [0.80.10] - 2026-07-16 ## [0.80.9] - 2026-07-16 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 3b55dc55..706324c7 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -212,6 +212,13 @@ interface EditorState { cursorCol: number; } +/** Undo snapshot: editor text state plus the paste registry. */ +interface EditorSnapshot { + state: EditorState; + pastes: Map; + pasteCounter: number; +} + interface LayoutLine { text: string; hasCursor: boolean; @@ -318,7 +325,7 @@ export class Editor implements Component, Focusable { private snappedFromCursorCol: number | null = null; // Undo support - private undoStack = new UndoStack(); + private undoStack = new UndoStack(); public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; @@ -999,13 +1006,13 @@ export class Editor implements Component, Focusable { this.cancelAutocomplete(); this.lastAction = null; this.exitHistoryBrowsing(); - this.pastes.clear(); - this.pasteCounter = 0; const normalized = this.normalizeText(text); // Push undo snapshot if content differs (makes programmatic changes undoable) if (this.getText() !== normalized) { this.pushUndoSnapshot(); } + this.pastes.clear(); + this.pasteCounter = 0; this.setTextInternal(normalized); } @@ -1284,17 +1291,21 @@ export class Editor implements Component, Focusable { this.pastes.delete(targetId); this.pasteCounter--; - // We got to update id of markers which are greater than the removed one + // Shift registry entries down in ascending id order, independent + // of marker order in the text ([paste #3] becomes [paste #2] when + // [paste #1] is removed). + const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b); + for (const id of higherIds) { + this.pastes.set(id - 1, this.pastes.get(id)!); + this.pastes.delete(id); + } + + // Renumber markers with ids greater than the removed one. this.state.lines = this.state.lines.map((line) => line.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => { const x = Number(idGroup); if (x <= targetId) return fullMatch; - - // [paste #3] become [paste #2] if we remove [paste #1] - const newText = `[paste #${x - 1}${suffixGroup}]`; - this.pastes.set(x - 1, this.pastes.get(x) ?? newText); - this.pastes.delete(x); - return newText; + return `[paste #${x - 1}${suffixGroup}]`; }), ); } @@ -1994,14 +2005,16 @@ export class Editor implements Component, Focusable { } private pushUndoSnapshot(): void { - this.undoStack.push(this.state); + this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter }); } private undo(): void { this.exitHistoryBrowsing(); const snapshot = this.undoStack.pop(); if (!snapshot) return; - Object.assign(this.state, snapshot); + Object.assign(this.state, snapshot.state); + this.pastes = snapshot.pastes; + this.pasteCounter = snapshot.pasteCounter; this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 0f33370e..d2c278d7 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -3553,6 +3553,11 @@ describe("Editor component", () => { return editor.getText(); } + /** Helper: 12-line paste content with a distinguishing tag */ + function bigPaste(tag: string): string { + return Array.from({ length: 12 }, (_, i) => `${tag}${i}`).join("\n"); + } + it("creates a paste marker for large pastes", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); const text = pasteWithMarker(editor); @@ -3690,6 +3695,76 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), textBefore); }); + it("undo after paste marker deletion restores the paste registry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.handleInput("\x7f"); // delete the marker + editor.handleInput("\x1b[45;5u"); // undo: restores marker text and registry + editor.handleInput("\r"); + assert.strictEqual(submitted, paste); + }); + + it("undo after deleting the first of two paste markers restores both registry entries", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const pasteA = bigPaste("alpha"); + const pasteB = bigPaste("beta"); + editor.handleInput(`\x1b[200~${pasteA}\x1b[201~`); // #1 = A + editor.handleInput(`\x1b[200~${pasteB}\x1b[201~`); // #2 = B, cursor at end + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[C"); // right over marker #1 + editor.handleInput("\x7f"); // delete marker #1, renumbers #2 -> #1 + editor.handleInput("\x1b[45;5u"); // undo + editor.handleInput("\r"); + assert.strictEqual(submitted, pasteA + pasteB); + }); + + it("renumbers the paste registry in ascending id order when markers are out of order in text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const pasteA = bigPaste("alpha"); + const pasteB = bigPaste("beta"); + const pasteC = bigPaste("gamma"); + editor.handleInput(`\x1b[200~${pasteA}\x1b[201~`); // #1 = A + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput(`\x1b[200~${pasteB}\x1b[201~`); // #2 = B, text: [#2][#1] + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput(`\x1b[200~${pasteC}\x1b[201~`); // #3 = C, text: [#3][#2][#1] + editor.handleInput("\x05"); // Ctrl+E + editor.handleInput("\x7f"); // delete marker #1, renumber #3 -> #2 and #2 -> #1 + editor.handleInput("\r"); + assert.strictEqual(submitted, pasteC + pasteB); + }); + + it("undo after setText restores paste markers and registry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.setText("replacement"); + editor.handleInput("\x1b[45;5u"); // undo + editor.handleInput("\r"); + assert.strictEqual(submitted, paste); + }); + it("handles multiple paste markers in same line", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); pasteWithMarker(editor);