Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
245 changes: 227 additions & 18 deletions src/tui/prompt-kill-ring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import {
beginYank,
breakKillSequence,
emptyKillRing,
killedTextBackward,
killedTextForward,
recordKill,
removedByDelete,
rotateYank,
} from "./prompt-kill-ring";

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
});
});
90 changes: 60 additions & 30 deletions src/tui/prompt-kill-ring.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 };
}
Loading
Loading