diff --git a/docs/TUI.md b/docs/TUI.md index 05abe1b2c..aeca179c0 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -899,8 +899,10 @@ without retyping it. The popup only splices a path into the prompt; submit inlines `@file` contents and summarizes `@dir` via `ingestOperatorPrompt`. A readline-style kill ring backs Ctrl+K/U/W (kill) and Ctrl+Y/Alt+Y -(yank/yank-pop) on top of the textarea's native delete bindings, which -otherwise discard what they delete (`src/tui/prompt-kill-ring.ts`). +(yank/yank-pop). Native textarea deletes discard the removed text, so the +ring reads that span from the edit buffer (`getTextRange` on the cursor the +delete is about to use) before the native action runs +(`src/tui/prompt-kill-ring.ts`). Consecutive kills in the same direction accumulate into one ring entry the way readline does, so a `Ctrl+K Ctrl+K … Ctrl+Y` sequence restores the whole killed run in original order. diff --git a/src/tui/prompt-kill-ring.test.ts b/src/tui/prompt-kill-ring.test.ts index 3545de869..27d5bc523 100644 --- a/src/tui/prompt-kill-ring.test.ts +++ b/src/tui/prompt-kill-ring.test.ts @@ -6,9 +6,8 @@ import { beginYank, breakKillSequence, emptyKillRing, - killedTextBackward, - killedTextForward, recordKill, + removedByDelete, rotateYank, } from "./prompt-kill-ring"; @@ -48,6 +47,42 @@ describe("recordKill / beginYank", () => { }); }); +describe("removedByDelete", () => { + test("an unchanged buffer records nothing", () => { + expect(removedByDelete("你", "你")).toEqual({ text: "", at: 0 }); + }); + + test("records the grapheme deleteRange dropped, not the overlapping tail", () => { + expect(removedByDelete("你好!", "你!")).toEqual({ text: "好", at: 1 }); + expect(removedByDelete("ab你cd", "abd")).toEqual({ text: "你c", at: 2 }); + }); + + test("a replacement that is not a pure deletion records nothing", () => { + expect(removedByDelete("abcd", "abXd")).toEqual({ text: "", at: 0 }); + }); + + test("a repeated prefix uses the caret hole, not the other copy", () => { + const hole = { + caret: 0, + measure: (text: string) => text.length, + }; + expect(removedByDelete("hello hello", "hello", hole)).toEqual({ + text: "hello ", + at: 0, + }); + expect(removedByDelete("aaa", "aa", hole)).toEqual({ text: "a", at: 0 }); + }); + + test("a caret that sits on no hole keeps the longest common prefix", () => { + expect( + removedByDelete("hello hello", "hello", { + caret: 7, + measure: (text: string) => text.length, + }), + ).toEqual({ text: " hello", at: 5 }); + }); +}); + describe("rotateYank", () => { test("rotates to the next-older entry after a yank", () => { let ring = recordKill(emptyKillRing, "second", "forward"); @@ -80,37 +115,211 @@ describe("rotateYank", () => { }); }); -describe("killedTextForward / killedTextBackward", () => { - test("forward diff reads the removed slice starting at the cursor", () => { - expect(killedTextForward("hello world", 5, "hello")).toBe(" world"); +describe("prompt kill ring chords", () => { + test("ctrl+k then ctrl+y restores the tail, including a wide glyph", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "hello 🚀 world"; + shell.prompt.cursorOffset = 6; + h.pressKey("k", { ctrl: true }); + expect(shell.prompt.value).toBe("hello "); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("hello 🚀 world"); + }); + }); + + test("ctrl+u then ctrl+y yanks the killed line back through mock keys", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + await h.mockInput.typeText("hello world"); + expect(shell.prompt.value).toBe("hello world"); + h.pressKey("u", { ctrl: true }); + expect(shell.prompt.value).toBe(""); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("hello world"); + }); }); - test("forward diff is empty when the buffer did not shrink", () => { - expect(killedTextForward("hello", 2, "hello")).toBe(""); + test("ctrl+u at column 0 kills the preceding newline and yank puts it back", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "hello\nworld"; + shell.prompt.cursorOffset = 6; + h.pressKey("u", { ctrl: true }); + expect(shell.prompt.value).toBe("helloworld"); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("hello\nworld"); + }); }); - test("backward diff reads the removed slice ending at the old cursor", () => { - expect(killedTextBackward("hello world", 11, 6)).toBe("world"); + test("consecutive ctrl+w kills accumulate and yank restores original order", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "foo bar"; + shell.prompt.cursorOffset = 7; + h.pressKey("w", { ctrl: true }); + expect(shell.prompt.value).toBe("foo "); + h.pressKey("w", { ctrl: true }); + expect(shell.prompt.value).toBe(""); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("foo bar"); + }); }); - test("backward diff is empty when the cursor did not move back", () => { - expect(killedTextBackward("hello", 3, 3)).toBe(""); + test("alt+d kills the next word and ctrl+y restores it", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "foo bar"; + shell.prompt.cursorOffset = 0; + h.pressKey("d", { meta: true }); + expect(shell.prompt.value).toBe("bar"); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("foo bar"); + }); }); -}); -describe("prompt kill ring chords", () => { - test("ctrl+u then ctrl+y yanks the killed line back through mock keys", async () => { + test("alt+y rotates to the next-older kill", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, run: "idle", }); - await h.mockInput.typeText("hello world"); - expect(shell.prompt.value).toBe("hello world"); + shell.prompt.value = "older"; + shell.prompt.cursorOffset = 0; + h.pressKey("k", { ctrl: true }); + h.pressKey("x"); + shell.prompt.value = "newer"; + shell.prompt.cursorOffset = 0; + h.pressKey("k", { ctrl: true }); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("newer"); + h.pressKey("y", { meta: true }); + expect(shell.prompt.value).toBe("older"); + }); + }); + + test("ctrl+u on a repeated prefix kills the span at the caret", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "hello hello"; + shell.prompt.cursorOffset = 6; h.pressKey("u", { ctrl: true }); - expect(shell.prompt.value).toBe(""); + expect(shell.prompt.value).toBe("hello"); + expect(shell.prompt.cursorOffset).toBe(0); + expect(shell.promptKillRing.entries[0]).toBe("hello "); h.pressKey("y", { ctrl: true }); - expect(shell.prompt.value).toBe("hello world"); + expect(shell.prompt.value).toBe("hello hello"); + }); + }); + + test("ctrl+u inside a run of the same character leaves the caret at the hole", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "aaa"; + shell.prompt.cursorOffset = 1; + h.pressKey("u", { ctrl: true }); + expect(shell.prompt.value).toBe("aa"); + expect(shell.prompt.cursorOffset).toBe(0); + h.pressKey("x"); + expect(shell.prompt.value).toBe("xaa"); + }); + }); + + test("ctrl+u on a unique prefix still kills that prefix", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "hello world"; + shell.prompt.cursorOffset = 6; + h.pressKey("u", { ctrl: true }); + expect(shell.prompt.value).toBe("world"); + expect(shell.prompt.cursorOffset).toBe(0); + expect(shell.promptKillRing.entries[0]).toBe("hello "); + h.pressKey("z", { shift: true }); + expect(shell.prompt.value).toBe("Zworld"); + }); + }); + + test("ctrl+k then ctrl+y on a parked wide glyph leaves it unchanged", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "你"; + h.pressKey("k", { ctrl: true }); + expect(shell.prompt.value).toBe("你"); + expect(shell.promptKillRing.entries).toEqual([]); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("你"); + }); + }); + + test("ctrl+k then ctrl+y restores a parked CJK line", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "你好!"; + h.pressKey("k", { ctrl: true }); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("你好!"); + }); + }); + + test("ctrl+w inside a wide glyph records nothing when the delete does not", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "ab你cd"; + shell.prompt.cursorOffset = 3; + h.pressKey("w", { ctrl: true }); + expect(shell.prompt.value).toBe("ab你cd"); + expect(shell.promptKillRing.entries).toEqual([]); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("ab你cd"); + }); + }); + + test("alt+d yank restores only the span deleteRange removed", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + shell.prompt.value = "ab你cd"; + shell.prompt.cursorOffset = 3; + h.pressKey("d", { meta: true }); + expect(shell.prompt.value).toBe("abd"); + expect(shell.promptKillRing.entries[0]).toBe("你c"); + h.pressKey("y", { ctrl: true }); + expect(shell.prompt.value).toBe("ab你cd"); }); }); }); diff --git a/src/tui/prompt-kill-ring.ts b/src/tui/prompt-kill-ring.ts index b489bfa4b..1ca32a827 100644 --- a/src/tui/prompt-kill-ring.ts +++ b/src/tui/prompt-kill-ring.ts @@ -1,12 +1,12 @@ /** * Readline-style kill ring for the OpenTUI prompt. * - * InputRenderable implements the delete and word-motion keys natively but - * discards the deleted text, so yank has nothing to restore. This module is - * the pure, testable half of that gap: shell.ts runs the native deletes, - * diffs value/cursor to learn what was removed, and records it via - * `recordKill`; `beginYank` / `rotateYank` hand back text for shell.ts to - * splice in. + * Textarea and Input delete actions discard the removed text (they return + * only whether they handled the key). The shell snapshots the buffer, runs + * the native delete, and records what `removedByDelete` says the buffer + * actually lost — a cursor-offset read can name a grapheme `deleteRange` + * snapped past or refused. `beginYank` / `rotateYank` hand back text for + * the shell to splice in. */ export const KILL_RING_MAX = 10; @@ -116,29 +116,59 @@ export function rotateYank( } /** - * Forward kill (Ctrl+K, Alt+D): the cursor does not move, so the killed text - * is the pre-delete slice at the pre-delete cursor, sized by the shrink. - */ -export function killedTextForward( - beforeValue: string, - beforeCursor: number, - afterValue: string, -): string { - const removedLen = beforeValue.length - afterValue.length; - if (removedLen <= 0) return ""; - return beforeValue.slice(beforeCursor, beforeCursor + removedLen); -} - -/** - * Backward kill (Ctrl+U, Ctrl+W): the cursor moves back to the deletion - * start, so the killed text is the pre-delete slice between the new and old - * cursor. + * Contiguous span `after` is missing from `before`. + * + * `deleteRange` no-ops or snaps when the caret sits inside a wide cell, and + * `getTextRange` still returns the overlapping grapheme. Recording that + * prediction yanks text the buffer never lost. `text` is empty when nothing + * was deleted or the change is not a pure deletion. `at` is the code-unit + * index of the span in `before` (and where it splices back into `after`). + * + * A repeated span has more than one such index. The longest common prefix + * is the rightmost, which is the other copy. `hole` is the native caret in + * `after` (display cells, via `measure`). The span whose splice point lands + * on that caret wins; if the caret sits on none of them, the longest common + * prefix stands so a snapped wide cell can still be parked. */ -export function killedTextBackward( - beforeValue: string, - beforeCursor: number, - afterCursor: number, -): string { - if (afterCursor >= beforeCursor) return ""; - return beforeValue.slice(afterCursor, beforeCursor); +export function removedByDelete( + before: string, + after: string, + hole?: { caret: number; measure: (text: string) => number }, +): { text: string; at: number } { + if (after.length > before.length || before === after) + return { text: "", at: 0 }; + let at = 0; + while (at < after.length && before.charCodeAt(at) === after.charCodeAt(at)) { + at += 1; + } + let beforeEnd = before.length; + let afterEnd = after.length; + while ( + afterEnd > at && + before.charCodeAt(beforeEnd - 1) === after.charCodeAt(afterEnd - 1) + ) { + beforeEnd -= 1; + afterEnd -= 1; + } + if (after !== `${before.slice(0, at)}${before.slice(beforeEnd)}`) { + return { text: "", at: 0 }; + } + const removedLen = beforeEnd - at; + let chosen = at; + if (hole !== undefined && removedLen > 0) { + let min = at; + while ( + min > 0 && + before.charCodeAt(min - 1) === before.charCodeAt(min - 1 + removedLen) + ) { + min -= 1; + } + for (let candidate = min; candidate < at; candidate += 1) { + if (hole.measure(after.slice(0, candidate)) === hole.caret) { + chosen = candidate; + break; + } + } + } + return { text: before.slice(chosen, chosen + removedLen), at: chosen }; } diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 0cf156169..cc7c6e0e5 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -78,9 +78,8 @@ import { canPopFocus, focusOwner, popFocus } from "../focus/index.js"; import { beginYank, breakKillSequence, - killedTextBackward, - killedTextForward, recordKill, + removedByDelete, rotateYank, } from "../prompt-kill-ring.js"; import { @@ -311,6 +310,34 @@ interface ShellKeyHandlers { onPaste: (event: { bytes: Uint8Array; preventDefault: () => void }) => void; } +function killThenRecord( + shell: AppShell, + direction: "forward" | "backward", + remove: (prompt: AppShell["prompt"]) => void, +): void { + const before = shell.prompt.value; + remove(shell.prompt); + const after = shell.prompt.value; + const measure = (prefix: string): number => + shell.prompt.editBuffer.measureText(prefix); + const removal = removedByDelete(before, after, { + caret: shell.prompt.cursorOffset, + measure, + }); + // A snapped wide cell leaves the caret off the grapheme it dropped. Yank + // inserts at the caret, so park it where the lost span splices back in. + // When the native caret already sits on that hole, this is a no-op and + // does not slide it onto the other copy of a repeated prefix. + if (removal.text.length > 0) { + shell.prompt.cursorOffset = measure(after.slice(0, removal.at)); + } + shell.promptKillRing = recordKill( + shell.promptKillRing, + removal.text, + direction, + ); +} + /** * The onKey/onPaste dispatcher bodies, extracted from createAppShell. * Paste-guard state stays in this closure: only these handlers read it. @@ -632,73 +659,33 @@ export function createShellKeyHandlers( if (key.ctrl && !key.meta && !key.option && keyName === "k") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteToLineEnd(); - const killed = killedTextForward( - before, - beforeCursor, - shell.prompt.value, - ); - shell.promptKillRing = recordKill( - shell.promptKillRing, - killed, - "forward", - ); + killThenRecord(shell, "forward", (prompt) => { + prompt.deleteToLineEnd(); + }); return; } if (key.ctrl && !key.meta && !key.option && keyName === "u") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteToLineStart(); - const killed = killedTextBackward( - before, - beforeCursor, - shell.prompt.cursorOffset, - ); - shell.promptKillRing = recordKill( - shell.promptKillRing, - killed, - "backward", - ); + killThenRecord(shell, "backward", (prompt) => { + prompt.deleteToLineStart(); + }); return; } if (key.ctrl && !key.meta && !key.option && keyName === "w") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteWordBackward(); - const killed = killedTextBackward( - before, - beforeCursor, - shell.prompt.cursorOffset, - ); - shell.promptKillRing = recordKill( - shell.promptKillRing, - killed, - "backward", - ); + killThenRecord(shell, "backward", (prompt) => { + prompt.deleteWordBackward(); + }); return; } if ((key.meta || key.option) && !key.ctrl && keyName === "d") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteWordForward(); - const killed = killedTextForward( - before, - beforeCursor, - shell.prompt.value, - ); - shell.promptKillRing = recordKill( - shell.promptKillRing, - killed, - "forward", - ); + killThenRecord(shell, "forward", (prompt) => { + prompt.deleteWordForward(); + }); return; }