From 9948b6227875373c533e94e7250829303105f1c9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 14:47:53 -0700 Subject: [PATCH 1/3] fix(tui): read kill spans from the edit buffer before native deletes Native Textarea and Input deletes still discard the removed text. Record the span with getTextRange on the cursor those deletes use, instead of diffing the prompt value after the fact. --- docs/TUI.md | 6 +- src/tui/prompt-kill-ring.test.ts | 100 +++++++++++++++++++++++++------ src/tui/prompt-kill-ring.ts | 78 +++++++++++++++--------- src/tui/shell/keys.ts | 38 +++--------- 4 files changed, 144 insertions(+), 78 deletions(-) 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..7b42087e8 100644 --- a/src/tui/prompt-kill-ring.test.ts +++ b/src/tui/prompt-kill-ring.test.ts @@ -6,8 +6,6 @@ import { beginYank, breakKillSequence, emptyKillRing, - killedTextBackward, - killedTextForward, recordKill, rotateYank, } from "./prompt-kill-ring"; @@ -80,25 +78,22 @@ describe("rotateYank", () => { }); }); -describe("killedTextForward / killedTextBackward", () => { - test("forward diff reads the removed slice starting at the cursor", () => { - expect(killedTextForward("hello world", 5, "hello")).toBe(" world"); - }); - - test("forward diff is empty when the buffer did not shrink", () => { - expect(killedTextForward("hello", 2, "hello")).toBe(""); - }); - - test("backward diff reads the removed slice ending at the old cursor", () => { - expect(killedTextBackward("hello world", 11, 6)).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("backward diff is empty when the cursor did not move back", () => { - expect(killedTextBackward("hello", 3, 3)).toBe(""); - }); -}); - -describe("prompt kill ring chords", () => { test("ctrl+u then ctrl+y yanks the killed line back through mock keys", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { @@ -113,4 +108,71 @@ describe("prompt kill ring chords", () => { expect(shell.prompt.value).toBe("hello world"); }); }); + + 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("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("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"); + }); + }); + + 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", + }); + 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"); + }); + }); }); diff --git a/src/tui/prompt-kill-ring.ts b/src/tui/prompt-kill-ring.ts index b489bfa4b..e64ff1abc 100644 --- a/src/tui/prompt-kill-ring.ts +++ b/src/tui/prompt-kill-ring.ts @@ -1,14 +1,16 @@ /** * 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 + * Textarea and Input delete actions discard the removed text (they return + * only whether they handled the key). The ring reads that span from the + * edit buffer — `getTextRange` on the cursor the native delete is about to + * use — then the shell runs the native action and records the span via + * `recordKill`. `beginYank` / `rotateYank` hand back text for the shell to * splice in. */ +import type { PromptInput } from "./prompt-input.js"; + export const KILL_RING_MAX = 10; export interface YankSpan { @@ -115,30 +117,52 @@ 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); +function rangeText(prompt: PromptInput, start: number, end: number): string { + return prompt.editBuffer.getTextRange(start, end); +} + +/** Span `deleteToLineEnd` is about to drop. The native call ignores a selection. */ +export function readKillToLineEnd(prompt: PromptInput): string { + const cursor = prompt.editBuffer.getCursorPosition(); + return rangeText(prompt, cursor.offset, prompt.editBuffer.getEOL().offset); } /** - * 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. + * Span `deleteToLineStart` is about to drop: the current line back to column + * 0, or the preceding newline when the cursor is already at column 0. */ -export function killedTextBackward( - beforeValue: string, - beforeCursor: number, - afterCursor: number, -): string { - if (afterCursor >= beforeCursor) return ""; - return beforeValue.slice(afterCursor, beforeCursor); +export function readKillToLineStart(prompt: PromptInput): string { + const cursor = prompt.editBuffer.getCursorPosition(); + if (cursor.col > 0) { + return rangeText( + prompt, + prompt.editBuffer.getLineStartOffset(cursor.row), + cursor.offset, + ); + } + if (cursor.row > 0) + return rangeText(prompt, cursor.offset - 1, cursor.offset); + return ""; +} + +/** Span `deleteWordForward` is about to drop, including a live selection. */ +export function readKillWordForward(prompt: PromptInput): string { + if (prompt.hasSelection()) return prompt.getSelectedText(); + const cursor = prompt.editBuffer.getCursorPosition(); + return rangeText( + prompt, + cursor.offset, + prompt.editBuffer.getNextWordBoundary().offset, + ); +} + +/** Span `deleteWordBackward` is about to drop, including a live selection. */ +export function readKillWordBackward(prompt: PromptInput): string { + if (prompt.hasSelection()) return prompt.getSelectedText(); + const cursor = prompt.editBuffer.getCursorPosition(); + return rangeText( + prompt, + prompt.editBuffer.getPrevWordBoundary().offset, + cursor.offset, + ); } diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 0cf156169..ec181ec22 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -78,8 +78,10 @@ import { canPopFocus, focusOwner, popFocus } from "../focus/index.js"; import { beginYank, breakKillSequence, - killedTextBackward, - killedTextForward, + readKillToLineEnd, + readKillToLineStart, + readKillWordBackward, + readKillWordForward, recordKill, rotateYank, } from "../prompt-kill-ring.js"; @@ -632,14 +634,8 @@ export function createShellKeyHandlers( if (key.ctrl && !key.meta && !key.option && keyName === "k") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; + const killed = readKillToLineEnd(shell.prompt); shell.prompt.deleteToLineEnd(); - const killed = killedTextForward( - before, - beforeCursor, - shell.prompt.value, - ); shell.promptKillRing = recordKill( shell.promptKillRing, killed, @@ -650,14 +646,8 @@ export function createShellKeyHandlers( if (key.ctrl && !key.meta && !key.option && keyName === "u") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; + const killed = readKillToLineStart(shell.prompt); shell.prompt.deleteToLineStart(); - const killed = killedTextBackward( - before, - beforeCursor, - shell.prompt.cursorOffset, - ); shell.promptKillRing = recordKill( shell.promptKillRing, killed, @@ -668,14 +658,8 @@ export function createShellKeyHandlers( if (key.ctrl && !key.meta && !key.option && keyName === "w") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; + const killed = readKillWordBackward(shell.prompt); shell.prompt.deleteWordBackward(); - const killed = killedTextBackward( - before, - beforeCursor, - shell.prompt.cursorOffset, - ); shell.promptKillRing = recordKill( shell.promptKillRing, killed, @@ -686,14 +670,8 @@ export function createShellKeyHandlers( if ((key.meta || key.option) && !key.ctrl && keyName === "d") { key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; + const killed = readKillWordForward(shell.prompt); shell.prompt.deleteWordForward(); - const killed = killedTextForward( - before, - beforeCursor, - shell.prompt.value, - ); shell.promptKillRing = recordKill( shell.promptKillRing, killed, From db78b833afa017abd367a3d04bbd4bfde234b60a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 15:06:31 -0700 Subject: [PATCH 2/3] fix(tui): record the kill the buffer actually lost deleteRange snaps or no-ops inside a wide cell, while a getTextRange prediction still names the overlapping grapheme. The ring stores the contiguous span the buffer actually lost, and the caret is parked where that span splices back so yank restores it. Refs CL-10644 --- src/tui/prompt-kill-ring.test.ts | 76 ++++++++++++++++++++++++++++ src/tui/prompt-kill-ring.ts | 86 +++++++++++++------------------- src/tui/shell/keys.ts | 68 +++++++++++++------------ 3 files changed, 146 insertions(+), 84 deletions(-) diff --git a/src/tui/prompt-kill-ring.test.ts b/src/tui/prompt-kill-ring.test.ts index 7b42087e8..fd2cf7dd4 100644 --- a/src/tui/prompt-kill-ring.test.ts +++ b/src/tui/prompt-kill-ring.test.ts @@ -7,6 +7,7 @@ import { breakKillSequence, emptyKillRing, recordKill, + removedByDelete, rotateYank, } from "./prompt-kill-ring"; @@ -46,6 +47,21 @@ 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 }); + }); +}); + describe("rotateYank", () => { test("rotates to the next-older entry after a yank", () => { let ring = recordKill(emptyKillRing, "second", "forward"); @@ -175,4 +191,64 @@ describe("prompt kill ring chords", () => { expect(shell.prompt.value).toBe("older"); }); }); + + 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 e64ff1abc..8296450a7 100644 --- a/src/tui/prompt-kill-ring.ts +++ b/src/tui/prompt-kill-ring.ts @@ -2,15 +2,13 @@ * Readline-style kill ring for the OpenTUI prompt. * * Textarea and Input delete actions discard the removed text (they return - * only whether they handled the key). The ring reads that span from the - * edit buffer — `getTextRange` on the cursor the native delete is about to - * use — then the shell runs the native action and records the span via - * `recordKill`. `beginYank` / `rotateYank` hand back text for the shell to - * splice in. + * 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. */ -import type { PromptInput } from "./prompt-input.js"; - export const KILL_RING_MAX = 10; export interface YankSpan { @@ -117,52 +115,36 @@ export function rotateYank( }; } -function rangeText(prompt: PromptInput, start: number, end: number): string { - return prompt.editBuffer.getTextRange(start, end); -} - -/** Span `deleteToLineEnd` is about to drop. The native call ignores a selection. */ -export function readKillToLineEnd(prompt: PromptInput): string { - const cursor = prompt.editBuffer.getCursorPosition(); - return rangeText(prompt, cursor.offset, prompt.editBuffer.getEOL().offset); -} - /** - * Span `deleteToLineStart` is about to drop: the current line back to column - * 0, or the preceding newline when the cursor is already at column 0. + * 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`). */ -export function readKillToLineStart(prompt: PromptInput): string { - const cursor = prompt.editBuffer.getCursorPosition(); - if (cursor.col > 0) { - return rangeText( - prompt, - prompt.editBuffer.getLineStartOffset(cursor.row), - cursor.offset, - ); +export function removedByDelete( + before: string, + after: string, +): { 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; } - if (cursor.row > 0) - return rangeText(prompt, cursor.offset - 1, cursor.offset); - return ""; -} - -/** Span `deleteWordForward` is about to drop, including a live selection. */ -export function readKillWordForward(prompt: PromptInput): string { - if (prompt.hasSelection()) return prompt.getSelectedText(); - const cursor = prompt.editBuffer.getCursorPosition(); - return rangeText( - prompt, - cursor.offset, - prompt.editBuffer.getNextWordBoundary().offset, - ); -} - -/** Span `deleteWordBackward` is about to drop, including a live selection. */ -export function readKillWordBackward(prompt: PromptInput): string { - if (prompt.hasSelection()) return prompt.getSelectedText(); - const cursor = prompt.editBuffer.getCursorPosition(); - return rangeText( - prompt, - prompt.editBuffer.getPrevWordBoundary().offset, - cursor.offset, - ); + 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 }; + } + return { text: before.slice(at, beforeEnd), at }; } diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index ec181ec22..04b3622cb 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -78,11 +78,8 @@ import { canPopFocus, focusOwner, popFocus } from "../focus/index.js"; import { beginYank, breakKillSequence, - readKillToLineEnd, - readKillToLineStart, - readKillWordBackward, - readKillWordForward, recordKill, + removedByDelete, rotateYank, } from "../prompt-kill-ring.js"; import { @@ -313,6 +310,29 @@ 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 removal = removedByDelete(before, after); + // deleteRange can leave the caret past a grapheme it snapped off. Yank + // inserts at the caret, so park it where the lost span splices back in. + if (removal.text.length > 0) { + shell.prompt.cursorOffset = shell.prompt.editBuffer.measureText( + 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. @@ -634,49 +654,33 @@ export function createShellKeyHandlers( if (key.ctrl && !key.meta && !key.option && keyName === "k") { key.preventDefault(); - const killed = readKillToLineEnd(shell.prompt); - shell.prompt.deleteToLineEnd(); - 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 killed = readKillToLineStart(shell.prompt); - shell.prompt.deleteToLineStart(); - 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 killed = readKillWordBackward(shell.prompt); - shell.prompt.deleteWordBackward(); - 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 killed = readKillWordForward(shell.prompt); - shell.prompt.deleteWordForward(); - shell.promptKillRing = recordKill( - shell.promptKillRing, - killed, - "forward", - ); + killThenRecord(shell, "forward", (prompt) => { + prompt.deleteWordForward(); + }); return; } From 00c2c916aedd7b922d0052275d49ee5cfad207bc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 15:18:48 -0700 Subject: [PATCH 3/3] fix(tui): keep a repeated kill on the caret hole The longest common prefix of a repeated span is the other copy, and parking the caret there yanks into the wrong hole. Prefer the span the native caret already sits on, and fall back to that prefix only when a snapped wide cell left the caret on none of them. Refs CL-10644 --- src/tui/prompt-kill-ring.test.ts | 71 ++++++++++++++++++++++++++++++++ src/tui/prompt-kill-ring.ts | 26 +++++++++++- src/tui/shell/keys.ts | 15 ++++--- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/tui/prompt-kill-ring.test.ts b/src/tui/prompt-kill-ring.test.ts index fd2cf7dd4..27d5bc523 100644 --- a/src/tui/prompt-kill-ring.test.ts +++ b/src/tui/prompt-kill-ring.test.ts @@ -60,6 +60,27 @@ describe("removedByDelete", () => { 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", () => { @@ -192,6 +213,56 @@ describe("prompt kill ring chords", () => { }); }); + 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("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 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, { diff --git a/src/tui/prompt-kill-ring.ts b/src/tui/prompt-kill-ring.ts index 8296450a7..1ca32a827 100644 --- a/src/tui/prompt-kill-ring.ts +++ b/src/tui/prompt-kill-ring.ts @@ -123,10 +123,17 @@ export function rotateYank( * 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 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 }; @@ -146,5 +153,22 @@ export function removedByDelete( if (after !== `${before.slice(0, at)}${before.slice(beforeEnd)}`) { return { text: "", at: 0 }; } - return { text: before.slice(at, beforeEnd), at }; + 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 04b3622cb..cc7c6e0e5 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -318,13 +318,18 @@ function killThenRecord( const before = shell.prompt.value; remove(shell.prompt); const after = shell.prompt.value; - const removal = removedByDelete(before, after); - // deleteRange can leave the caret past a grapheme it snapped off. Yank + 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 = shell.prompt.editBuffer.measureText( - after.slice(0, removal.at), - ); + shell.prompt.cursorOffset = measure(after.slice(0, removal.at)); } shell.promptKillRing = recordKill( shell.promptKillRing,