Skip to content

Commit ddfdbc3

Browse files
Merge pull request #1465 from corbitsdev/cl-10644-tui-adopt-textbuffer-textarea-input-natives
fix(tui): read kill spans from the edit buffer before native deletes
2 parents 5a251e1 + 00c2c91 commit ddfdbc3

4 files changed

Lines changed: 332 additions & 104 deletions

File tree

‎docs/TUI.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -899,8 +899,10 @@ without retyping it. The popup only splices a path into the prompt; submit
899899
inlines `@file` contents and summarizes `@dir` via `ingestOperatorPrompt`.
900900

901901
A readline-style kill ring backs Ctrl+K/U/W (kill) and Ctrl+Y/Alt+Y
902-
(yank/yank-pop) on top of the textarea's native delete bindings, which
903-
otherwise discard what they delete (`src/tui/prompt-kill-ring.ts`).
902+
(yank/yank-pop). Native textarea deletes discard the removed text, so the
903+
ring reads that span from the edit buffer (`getTextRange` on the cursor the
904+
delete is about to use) before the native action runs
905+
(`src/tui/prompt-kill-ring.ts`).
904906
Consecutive kills in the same direction accumulate into one ring entry the
905907
way readline does, so a `Ctrl+K Ctrl+K … Ctrl+Y` sequence restores the whole
906908
killed run in original order.

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

Lines changed: 227 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import {
66
beginYank,
77
breakKillSequence,
88
emptyKillRing,
9-
killedTextBackward,
10-
killedTextForward,
119
recordKill,
10+
removedByDelete,
1211
rotateYank,
1312
} from "./prompt-kill-ring";
1413

@@ -48,6 +47,42 @@ describe("recordKill / beginYank", () => {
4847
});
4948
});
5049

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+
test("a repeated prefix uses the caret hole, not the other copy", () => {
65+
const hole = {
66+
caret: 0,
67+
measure: (text: string) => text.length,
68+
};
69+
expect(removedByDelete("hello hello", "hello", hole)).toEqual({
70+
text: "hello ",
71+
at: 0,
72+
});
73+
expect(removedByDelete("aaa", "aa", hole)).toEqual({ text: "a", at: 0 });
74+
});
75+
76+
test("a caret that sits on no hole keeps the longest common prefix", () => {
77+
expect(
78+
removedByDelete("hello hello", "hello", {
79+
caret: 7,
80+
measure: (text: string) => text.length,
81+
}),
82+
).toEqual({ text: " hello", at: 5 });
83+
});
84+
});
85+
5186
describe("rotateYank", () => {
5287
test("rotates to the next-older entry after a yank", () => {
5388
let ring = recordKill(emptyKillRing, "second", "forward");
@@ -80,37 +115,211 @@ describe("rotateYank", () => {
80115
});
81116
});
82117

83-
describe("killedTextForward / killedTextBackward", () => {
84-
test("forward diff reads the removed slice starting at the cursor", () => {
85-
expect(killedTextForward("hello world", 5, "hello")).toBe(" world");
118+
describe("prompt kill ring chords", () => {
119+
test("ctrl+k then ctrl+y restores the tail, including a wide glyph", async () => {
120+
await withTestRenderer(async (h) => {
121+
const shell = createAppShell(h.renderer, {
122+
terminal: { columns: 80, rows: 24 },
123+
run: "idle",
124+
});
125+
shell.prompt.value = "hello 🚀 world";
126+
shell.prompt.cursorOffset = 6;
127+
h.pressKey("k", { ctrl: true });
128+
expect(shell.prompt.value).toBe("hello ");
129+
h.pressKey("y", { ctrl: true });
130+
expect(shell.prompt.value).toBe("hello 🚀 world");
131+
});
132+
});
133+
134+
test("ctrl+u then ctrl+y yanks the killed line back through mock keys", async () => {
135+
await withTestRenderer(async (h) => {
136+
const shell = createAppShell(h.renderer, {
137+
terminal: { columns: 80, rows: 24 },
138+
run: "idle",
139+
});
140+
await h.mockInput.typeText("hello world");
141+
expect(shell.prompt.value).toBe("hello world");
142+
h.pressKey("u", { ctrl: true });
143+
expect(shell.prompt.value).toBe("");
144+
h.pressKey("y", { ctrl: true });
145+
expect(shell.prompt.value).toBe("hello world");
146+
});
86147
});
87148

88-
test("forward diff is empty when the buffer did not shrink", () => {
89-
expect(killedTextForward("hello", 2, "hello")).toBe("");
149+
test("ctrl+u at column 0 kills the preceding newline and yank puts it back", async () => {
150+
await withTestRenderer(async (h) => {
151+
const shell = createAppShell(h.renderer, {
152+
terminal: { columns: 80, rows: 24 },
153+
run: "idle",
154+
});
155+
shell.prompt.value = "hello\nworld";
156+
shell.prompt.cursorOffset = 6;
157+
h.pressKey("u", { ctrl: true });
158+
expect(shell.prompt.value).toBe("helloworld");
159+
h.pressKey("y", { ctrl: true });
160+
expect(shell.prompt.value).toBe("hello\nworld");
161+
});
90162
});
91163

92-
test("backward diff reads the removed slice ending at the old cursor", () => {
93-
expect(killedTextBackward("hello world", 11, 6)).toBe("world");
164+
test("consecutive ctrl+w kills accumulate and yank restores original order", async () => {
165+
await withTestRenderer(async (h) => {
166+
const shell = createAppShell(h.renderer, {
167+
terminal: { columns: 80, rows: 24 },
168+
run: "idle",
169+
});
170+
shell.prompt.value = "foo bar";
171+
shell.prompt.cursorOffset = 7;
172+
h.pressKey("w", { ctrl: true });
173+
expect(shell.prompt.value).toBe("foo ");
174+
h.pressKey("w", { ctrl: true });
175+
expect(shell.prompt.value).toBe("");
176+
h.pressKey("y", { ctrl: true });
177+
expect(shell.prompt.value).toBe("foo bar");
178+
});
94179
});
95180

96-
test("backward diff is empty when the cursor did not move back", () => {
97-
expect(killedTextBackward("hello", 3, 3)).toBe("");
181+
test("alt+d kills the next word and ctrl+y restores it", async () => {
182+
await withTestRenderer(async (h) => {
183+
const shell = createAppShell(h.renderer, {
184+
terminal: { columns: 80, rows: 24 },
185+
run: "idle",
186+
});
187+
shell.prompt.value = "foo bar";
188+
shell.prompt.cursorOffset = 0;
189+
h.pressKey("d", { meta: true });
190+
expect(shell.prompt.value).toBe("bar");
191+
h.pressKey("y", { ctrl: true });
192+
expect(shell.prompt.value).toBe("foo bar");
193+
});
98194
});
99-
});
100195

101-
describe("prompt kill ring chords", () => {
102-
test("ctrl+u then ctrl+y yanks the killed line back through mock keys", async () => {
196+
test("alt+y rotates to the next-older kill", async () => {
103197
await withTestRenderer(async (h) => {
104198
const shell = createAppShell(h.renderer, {
105199
terminal: { columns: 80, rows: 24 },
106200
run: "idle",
107201
});
108-
await h.mockInput.typeText("hello world");
109-
expect(shell.prompt.value).toBe("hello world");
202+
shell.prompt.value = "older";
203+
shell.prompt.cursorOffset = 0;
204+
h.pressKey("k", { ctrl: true });
205+
h.pressKey("x");
206+
shell.prompt.value = "newer";
207+
shell.prompt.cursorOffset = 0;
208+
h.pressKey("k", { ctrl: true });
209+
h.pressKey("y", { ctrl: true });
210+
expect(shell.prompt.value).toBe("newer");
211+
h.pressKey("y", { meta: true });
212+
expect(shell.prompt.value).toBe("older");
213+
});
214+
});
215+
216+
test("ctrl+u on a repeated prefix kills the span at the caret", async () => {
217+
await withTestRenderer(async (h) => {
218+
const shell = createAppShell(h.renderer, {
219+
terminal: { columns: 80, rows: 24 },
220+
run: "idle",
221+
});
222+
shell.prompt.value = "hello hello";
223+
shell.prompt.cursorOffset = 6;
110224
h.pressKey("u", { ctrl: true });
111-
expect(shell.prompt.value).toBe("");
225+
expect(shell.prompt.value).toBe("hello");
226+
expect(shell.prompt.cursorOffset).toBe(0);
227+
expect(shell.promptKillRing.entries[0]).toBe("hello ");
112228
h.pressKey("y", { ctrl: true });
113-
expect(shell.prompt.value).toBe("hello world");
229+
expect(shell.prompt.value).toBe("hello hello");
230+
});
231+
});
232+
233+
test("ctrl+u inside a run of the same character leaves the caret at the hole", async () => {
234+
await withTestRenderer(async (h) => {
235+
const shell = createAppShell(h.renderer, {
236+
terminal: { columns: 80, rows: 24 },
237+
run: "idle",
238+
});
239+
shell.prompt.value = "aaa";
240+
shell.prompt.cursorOffset = 1;
241+
h.pressKey("u", { ctrl: true });
242+
expect(shell.prompt.value).toBe("aa");
243+
expect(shell.prompt.cursorOffset).toBe(0);
244+
h.pressKey("x");
245+
expect(shell.prompt.value).toBe("xaa");
246+
});
247+
});
248+
249+
test("ctrl+u on a unique prefix still kills that prefix", async () => {
250+
await withTestRenderer(async (h) => {
251+
const shell = createAppShell(h.renderer, {
252+
terminal: { columns: 80, rows: 24 },
253+
run: "idle",
254+
});
255+
shell.prompt.value = "hello world";
256+
shell.prompt.cursorOffset = 6;
257+
h.pressKey("u", { ctrl: true });
258+
expect(shell.prompt.value).toBe("world");
259+
expect(shell.prompt.cursorOffset).toBe(0);
260+
expect(shell.promptKillRing.entries[0]).toBe("hello ");
261+
h.pressKey("z", { shift: true });
262+
expect(shell.prompt.value).toBe("Zworld");
263+
});
264+
});
265+
266+
test("ctrl+k then ctrl+y on a parked wide glyph leaves it unchanged", async () => {
267+
await withTestRenderer(async (h) => {
268+
const shell = createAppShell(h.renderer, {
269+
terminal: { columns: 80, rows: 24 },
270+
run: "idle",
271+
});
272+
shell.prompt.value = "你";
273+
h.pressKey("k", { ctrl: true });
274+
expect(shell.prompt.value).toBe("你");
275+
expect(shell.promptKillRing.entries).toEqual([]);
276+
h.pressKey("y", { ctrl: true });
277+
expect(shell.prompt.value).toBe("你");
278+
});
279+
});
280+
281+
test("ctrl+k then ctrl+y restores a parked CJK line", async () => {
282+
await withTestRenderer(async (h) => {
283+
const shell = createAppShell(h.renderer, {
284+
terminal: { columns: 80, rows: 24 },
285+
run: "idle",
286+
});
287+
shell.prompt.value = "你好!";
288+
h.pressKey("k", { ctrl: true });
289+
h.pressKey("y", { ctrl: true });
290+
expect(shell.prompt.value).toBe("你好!");
291+
});
292+
});
293+
294+
test("ctrl+w inside a wide glyph records nothing when the delete does not", async () => {
295+
await withTestRenderer(async (h) => {
296+
const shell = createAppShell(h.renderer, {
297+
terminal: { columns: 80, rows: 24 },
298+
run: "idle",
299+
});
300+
shell.prompt.value = "ab你cd";
301+
shell.prompt.cursorOffset = 3;
302+
h.pressKey("w", { ctrl: true });
303+
expect(shell.prompt.value).toBe("ab你cd");
304+
expect(shell.promptKillRing.entries).toEqual([]);
305+
h.pressKey("y", { ctrl: true });
306+
expect(shell.prompt.value).toBe("ab你cd");
307+
});
308+
});
309+
310+
test("alt+d yank restores only the span deleteRange removed", async () => {
311+
await withTestRenderer(async (h) => {
312+
const shell = createAppShell(h.renderer, {
313+
terminal: { columns: 80, rows: 24 },
314+
run: "idle",
315+
});
316+
shell.prompt.value = "ab你cd";
317+
shell.prompt.cursorOffset = 3;
318+
h.pressKey("d", { meta: true });
319+
expect(shell.prompt.value).toBe("abd");
320+
expect(shell.promptKillRing.entries[0]).toBe("你c");
321+
h.pressKey("y", { ctrl: true });
322+
expect(shell.prompt.value).toBe("ab你cd");
114323
});
115324
});
116325
});

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

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/**
22
* Readline-style kill ring for the OpenTUI prompt.
33
*
4-
* InputRenderable implements the delete and word-motion keys natively but
5-
* discards the deleted text, so yank has nothing to restore. This module is
6-
* the pure, testable half of that gap: shell.ts runs the native deletes,
7-
* diffs value/cursor to learn what was removed, and records it via
8-
* `recordKill`; `beginYank` / `rotateYank` hand back text for shell.ts to
9-
* splice in.
4+
* Textarea and Input delete actions discard the removed text (they return
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

1212
export const KILL_RING_MAX = 10;
@@ -116,29 +116,59 @@ export function rotateYank(
116116
}
117117

118118
/**
119-
* Forward kill (Ctrl+K, Alt+D): the cursor does not move, so the killed text
120-
* is the pre-delete slice at the pre-delete cursor, sized by the shrink.
121-
*/
122-
export function killedTextForward(
123-
beforeValue: string,
124-
beforeCursor: number,
125-
afterValue: string,
126-
): string {
127-
const removedLen = beforeValue.length - afterValue.length;
128-
if (removedLen <= 0) return "";
129-
return beforeValue.slice(beforeCursor, beforeCursor + removedLen);
130-
}
131-
132-
/**
133-
* Backward kill (Ctrl+U, Ctrl+W): the cursor moves back to the deletion
134-
* start, so the killed text is the pre-delete slice between the new and old
135-
* cursor.
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`).
126+
*
127+
* A repeated span has more than one such index. The longest common prefix
128+
* is the rightmost, which is the other copy. `hole` is the native caret in
129+
* `after` (display cells, via `measure`). The span whose splice point lands
130+
* on that caret wins; if the caret sits on none of them, the longest common
131+
* prefix stands so a snapped wide cell can still be parked.
136132
*/
137-
export function killedTextBackward(
138-
beforeValue: string,
139-
beforeCursor: number,
140-
afterCursor: number,
141-
): string {
142-
if (afterCursor >= beforeCursor) return "";
143-
return beforeValue.slice(afterCursor, beforeCursor);
133+
export function removedByDelete(
134+
before: string,
135+
after: string,
136+
hole?: { caret: number; measure: (text: string) => number },
137+
): { text: string; at: number } {
138+
if (after.length > before.length || before === after)
139+
return { text: "", at: 0 };
140+
let at = 0;
141+
while (at < after.length && before.charCodeAt(at) === after.charCodeAt(at)) {
142+
at += 1;
143+
}
144+
let beforeEnd = before.length;
145+
let afterEnd = after.length;
146+
while (
147+
afterEnd > at &&
148+
before.charCodeAt(beforeEnd - 1) === after.charCodeAt(afterEnd - 1)
149+
) {
150+
beforeEnd -= 1;
151+
afterEnd -= 1;
152+
}
153+
if (after !== `${before.slice(0, at)}${before.slice(beforeEnd)}`) {
154+
return { text: "", at: 0 };
155+
}
156+
const removedLen = beforeEnd - at;
157+
let chosen = at;
158+
if (hole !== undefined && removedLen > 0) {
159+
let min = at;
160+
while (
161+
min > 0 &&
162+
before.charCodeAt(min - 1) === before.charCodeAt(min - 1 + removedLen)
163+
) {
164+
min -= 1;
165+
}
166+
for (let candidate = min; candidate < at; candidate += 1) {
167+
if (hole.measure(after.slice(0, candidate)) === hole.caret) {
168+
chosen = candidate;
169+
break;
170+
}
171+
}
172+
}
173+
return { text: before.slice(chosen, chosen + removedLen), at: chosen };
144174
}

0 commit comments

Comments
 (0)