Skip to content

Commit db78b83

Browse files
committed
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
1 parent 9948b62 commit db78b83

3 files changed

Lines changed: 146 additions & 84 deletions

File tree

‎src/tui/prompt-kill-ring.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
breakKillSequence,
88
emptyKillRing,
99
recordKill,
10+
removedByDelete,
1011
rotateYank,
1112
} from "./prompt-kill-ring";
1213

@@ -46,6 +47,21 @@ describe("recordKill / beginYank", () => {
4647
});
4748
});
4849

50+
describe("removedByDelete", () => {
51+
test("an unchanged buffer records nothing", () => {
52+
expect(removedByDelete("你", "你")).toEqual({ text: "", at: 0 });
53+
});
54+
55+
test("records the grapheme deleteRange dropped, not the overlapping tail", () => {
56+
expect(removedByDelete("你好!", "你!")).toEqual({ text: "好", at: 1 });
57+
expect(removedByDelete("ab你cd", "abd")).toEqual({ text: "你c", at: 2 });
58+
});
59+
60+
test("a replacement that is not a pure deletion records nothing", () => {
61+
expect(removedByDelete("abcd", "abXd")).toEqual({ text: "", at: 0 });
62+
});
63+
});
64+
4965
describe("rotateYank", () => {
5066
test("rotates to the next-older entry after a yank", () => {
5167
let ring = recordKill(emptyKillRing, "second", "forward");
@@ -175,4 +191,64 @@ describe("prompt kill ring chords", () => {
175191
expect(shell.prompt.value).toBe("older");
176192
});
177193
});
194+
195+
test("ctrl+k then ctrl+y on a parked wide glyph leaves it unchanged", async () => {
196+
await withTestRenderer(async (h) => {
197+
const shell = createAppShell(h.renderer, {
198+
terminal: { columns: 80, rows: 24 },
199+
run: "idle",
200+
});
201+
shell.prompt.value = "你";
202+
h.pressKey("k", { ctrl: true });
203+
expect(shell.prompt.value).toBe("你");
204+
expect(shell.promptKillRing.entries).toEqual([]);
205+
h.pressKey("y", { ctrl: true });
206+
expect(shell.prompt.value).toBe("你");
207+
});
208+
});
209+
210+
test("ctrl+k then ctrl+y restores a parked CJK line", async () => {
211+
await withTestRenderer(async (h) => {
212+
const shell = createAppShell(h.renderer, {
213+
terminal: { columns: 80, rows: 24 },
214+
run: "idle",
215+
});
216+
shell.prompt.value = "你好!";
217+
h.pressKey("k", { ctrl: true });
218+
h.pressKey("y", { ctrl: true });
219+
expect(shell.prompt.value).toBe("你好!");
220+
});
221+
});
222+
223+
test("ctrl+w inside a wide glyph records nothing when the delete does not", async () => {
224+
await withTestRenderer(async (h) => {
225+
const shell = createAppShell(h.renderer, {
226+
terminal: { columns: 80, rows: 24 },
227+
run: "idle",
228+
});
229+
shell.prompt.value = "ab你cd";
230+
shell.prompt.cursorOffset = 3;
231+
h.pressKey("w", { ctrl: true });
232+
expect(shell.prompt.value).toBe("ab你cd");
233+
expect(shell.promptKillRing.entries).toEqual([]);
234+
h.pressKey("y", { ctrl: true });
235+
expect(shell.prompt.value).toBe("ab你cd");
236+
});
237+
});
238+
239+
test("alt+d yank restores only the span deleteRange removed", async () => {
240+
await withTestRenderer(async (h) => {
241+
const shell = createAppShell(h.renderer, {
242+
terminal: { columns: 80, rows: 24 },
243+
run: "idle",
244+
});
245+
shell.prompt.value = "ab你cd";
246+
shell.prompt.cursorOffset = 3;
247+
h.pressKey("d", { meta: true });
248+
expect(shell.prompt.value).toBe("abd");
249+
expect(shell.promptKillRing.entries[0]).toBe("你c");
250+
h.pressKey("y", { ctrl: true });
251+
expect(shell.prompt.value).toBe("ab你cd");
252+
});
253+
});
178254
});

‎src/tui/prompt-kill-ring.ts‎

Lines changed: 34 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22
* Readline-style kill ring for the OpenTUI prompt.
33
*
44
* Textarea and Input delete actions discard the removed text (they return
5-
* only whether they handled the key). The ring reads that span from the
6-
* edit buffer — `getTextRange` on the cursor the native delete is about to
7-
* use — then the shell runs the native action and records the span via
8-
* `recordKill`. `beginYank` / `rotateYank` hand back text for the shell to
9-
* splice in.
5+
* only whether they handled the key). The shell snapshots the buffer, runs
6+
* the native delete, and records what `removedByDelete` says the buffer
7+
* actually lost — a cursor-offset read can name a grapheme `deleteRange`
8+
* snapped past or refused. `beginYank` / `rotateYank` hand back text for
9+
* the shell to splice in.
1010
*/
1111

12-
import type { PromptInput } from "./prompt-input.js";
13-
1412
export const KILL_RING_MAX = 10;
1513

1614
export interface YankSpan {
@@ -117,52 +115,36 @@ export function rotateYank(
117115
};
118116
}
119117

120-
function rangeText(prompt: PromptInput, start: number, end: number): string {
121-
return prompt.editBuffer.getTextRange(start, end);
122-
}
123-
124-
/** Span `deleteToLineEnd` is about to drop. The native call ignores a selection. */
125-
export function readKillToLineEnd(prompt: PromptInput): string {
126-
const cursor = prompt.editBuffer.getCursorPosition();
127-
return rangeText(prompt, cursor.offset, prompt.editBuffer.getEOL().offset);
128-
}
129-
130118
/**
131-
* Span `deleteToLineStart` is about to drop: the current line back to column
132-
* 0, or the preceding newline when the cursor is already at column 0.
119+
* Contiguous span `after` is missing from `before`.
120+
*
121+
* `deleteRange` no-ops or snaps when the caret sits inside a wide cell, and
122+
* `getTextRange` still returns the overlapping grapheme. Recording that
123+
* prediction yanks text the buffer never lost. `text` is empty when nothing
124+
* was deleted or the change is not a pure deletion. `at` is the code-unit
125+
* index of the span in `before` (and where it splices back into `after`).
133126
*/
134-
export function readKillToLineStart(prompt: PromptInput): string {
135-
const cursor = prompt.editBuffer.getCursorPosition();
136-
if (cursor.col > 0) {
137-
return rangeText(
138-
prompt,
139-
prompt.editBuffer.getLineStartOffset(cursor.row),
140-
cursor.offset,
141-
);
127+
export function removedByDelete(
128+
before: string,
129+
after: string,
130+
): { text: string; at: number } {
131+
if (after.length > before.length || before === after)
132+
return { text: "", at: 0 };
133+
let at = 0;
134+
while (at < after.length && before.charCodeAt(at) === after.charCodeAt(at)) {
135+
at += 1;
142136
}
143-
if (cursor.row > 0)
144-
return rangeText(prompt, cursor.offset - 1, cursor.offset);
145-
return "";
146-
}
147-
148-
/** Span `deleteWordForward` is about to drop, including a live selection. */
149-
export function readKillWordForward(prompt: PromptInput): string {
150-
if (prompt.hasSelection()) return prompt.getSelectedText();
151-
const cursor = prompt.editBuffer.getCursorPosition();
152-
return rangeText(
153-
prompt,
154-
cursor.offset,
155-
prompt.editBuffer.getNextWordBoundary().offset,
156-
);
157-
}
158-
159-
/** Span `deleteWordBackward` is about to drop, including a live selection. */
160-
export function readKillWordBackward(prompt: PromptInput): string {
161-
if (prompt.hasSelection()) return prompt.getSelectedText();
162-
const cursor = prompt.editBuffer.getCursorPosition();
163-
return rangeText(
164-
prompt,
165-
prompt.editBuffer.getPrevWordBoundary().offset,
166-
cursor.offset,
167-
);
137+
let beforeEnd = before.length;
138+
let afterEnd = after.length;
139+
while (
140+
afterEnd > at &&
141+
before.charCodeAt(beforeEnd - 1) === after.charCodeAt(afterEnd - 1)
142+
) {
143+
beforeEnd -= 1;
144+
afterEnd -= 1;
145+
}
146+
if (after !== `${before.slice(0, at)}${before.slice(beforeEnd)}`) {
147+
return { text: "", at: 0 };
148+
}
149+
return { text: before.slice(at, beforeEnd), at };
168150
}

‎src/tui/shell/keys.ts‎

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,8 @@ import { canPopFocus, focusOwner, popFocus } from "../focus/index.js";
7878
import {
7979
beginYank,
8080
breakKillSequence,
81-
readKillToLineEnd,
82-
readKillToLineStart,
83-
readKillWordBackward,
84-
readKillWordForward,
8581
recordKill,
82+
removedByDelete,
8683
rotateYank,
8784
} from "../prompt-kill-ring.js";
8885
import {
@@ -313,6 +310,29 @@ interface ShellKeyHandlers {
313310
onPaste: (event: { bytes: Uint8Array; preventDefault: () => void }) => void;
314311
}
315312

313+
function killThenRecord(
314+
shell: AppShell,
315+
direction: "forward" | "backward",
316+
remove: (prompt: AppShell["prompt"]) => void,
317+
): void {
318+
const before = shell.prompt.value;
319+
remove(shell.prompt);
320+
const after = shell.prompt.value;
321+
const removal = removedByDelete(before, after);
322+
// deleteRange can leave the caret past a grapheme it snapped off. Yank
323+
// inserts at the caret, so park it where the lost span splices back in.
324+
if (removal.text.length > 0) {
325+
shell.prompt.cursorOffset = shell.prompt.editBuffer.measureText(
326+
after.slice(0, removal.at),
327+
);
328+
}
329+
shell.promptKillRing = recordKill(
330+
shell.promptKillRing,
331+
removal.text,
332+
direction,
333+
);
334+
}
335+
316336
/**
317337
* The onKey/onPaste dispatcher bodies, extracted from createAppShell.
318338
* Paste-guard state stays in this closure: only these handlers read it.
@@ -634,49 +654,33 @@ export function createShellKeyHandlers(
634654

635655
if (key.ctrl && !key.meta && !key.option && keyName === "k") {
636656
key.preventDefault();
637-
const killed = readKillToLineEnd(shell.prompt);
638-
shell.prompt.deleteToLineEnd();
639-
shell.promptKillRing = recordKill(
640-
shell.promptKillRing,
641-
killed,
642-
"forward",
643-
);
657+
killThenRecord(shell, "forward", (prompt) => {
658+
prompt.deleteToLineEnd();
659+
});
644660
return;
645661
}
646662

647663
if (key.ctrl && !key.meta && !key.option && keyName === "u") {
648664
key.preventDefault();
649-
const killed = readKillToLineStart(shell.prompt);
650-
shell.prompt.deleteToLineStart();
651-
shell.promptKillRing = recordKill(
652-
shell.promptKillRing,
653-
killed,
654-
"backward",
655-
);
665+
killThenRecord(shell, "backward", (prompt) => {
666+
prompt.deleteToLineStart();
667+
});
656668
return;
657669
}
658670

659671
if (key.ctrl && !key.meta && !key.option && keyName === "w") {
660672
key.preventDefault();
661-
const killed = readKillWordBackward(shell.prompt);
662-
shell.prompt.deleteWordBackward();
663-
shell.promptKillRing = recordKill(
664-
shell.promptKillRing,
665-
killed,
666-
"backward",
667-
);
673+
killThenRecord(shell, "backward", (prompt) => {
674+
prompt.deleteWordBackward();
675+
});
668676
return;
669677
}
670678

671679
if ((key.meta || key.option) && !key.ctrl && keyName === "d") {
672680
key.preventDefault();
673-
const killed = readKillWordForward(shell.prompt);
674-
shell.prompt.deleteWordForward();
675-
shell.promptKillRing = recordKill(
676-
shell.promptKillRing,
677-
killed,
678-
"forward",
679-
);
681+
killThenRecord(shell, "forward", (prompt) => {
682+
prompt.deleteWordForward();
683+
});
680684
return;
681685
}
682686

0 commit comments

Comments
 (0)