mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 10:05:14 +00:00
Merge branch 'feat/kill-ring' into merge-810
This commit is contained in:
commit
fde9b089f4
9 changed files with 686 additions and 2 deletions
|
|
@ -7,6 +7,7 @@
|
|||
- Export `keyHint`, `appKeyHint`, `editorKey`, `appKey`, `rawKeyHint` for extensions to format keybinding hints consistently
|
||||
- Added `showHardwareCursor` setting to control cursor visibility while still positioning it for IME support. ([#800](https://github.com/badlogic/pi-mono/pull/800) by [@ghoulr](https://github.com/ghoulr))
|
||||
- Added `ctx.compact()` and `ctx.getContextUsage()` to extension contexts for programmatic compaction and context usage checks.
|
||||
- Added documentation for delete word forward and kill ring keybindings in interactive mode. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence))
|
||||
|
||||
## [0.48.0] - 2026-01-16
|
||||
|
||||
|
|
|
|||
|
|
@ -350,8 +350,11 @@ Both modes are configurable via `/settings`: "one-at-a-time" delivers messages o
|
|||
| Enter | Send message |
|
||||
| Shift+Enter | New line (Ctrl+Enter on Windows Terminal) |
|
||||
| Ctrl+W / Option+Backspace | Delete word backwards |
|
||||
| Alt+D | Delete word forwards |
|
||||
| Ctrl+U | Delete to start of line |
|
||||
| Ctrl+K | Delete to end of line |
|
||||
| Ctrl+Y | Paste most recently deleted text |
|
||||
| Alt+Y | Cycle through deleted text after pasting |
|
||||
|
||||
**Other:**
|
||||
|
||||
|
|
@ -397,8 +400,11 @@ All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Eac
|
|||
| `deleteCharBackward` | `backspace` | Delete char backward |
|
||||
| `deleteCharForward` | `delete` | Delete char forward |
|
||||
| `deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward |
|
||||
| `deleteWordForward` | `alt+d` | Delete word forward |
|
||||
| `deleteToLineStart` | `ctrl+u` | Delete to line start |
|
||||
| `deleteToLineEnd` | `ctrl+k` | Delete to line end |
|
||||
| `yank` | `ctrl+y` | Paste most recently deleted text |
|
||||
| `yankPop` | `alt+y` | Cycle through deleted text after pasting |
|
||||
| `newLine` | `shift+enter` | Insert new line |
|
||||
| `submit` | `enter` | Submit input |
|
||||
| `tab` | `tab` | Tab/autocomplete |
|
||||
|
|
|
|||
|
|
@ -3437,8 +3437,11 @@ export class InteractiveMode {
|
|||
const submit = this.getEditorKeyDisplay("submit");
|
||||
const newLine = this.getEditorKeyDisplay("newLine");
|
||||
const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward");
|
||||
const deleteWordForward = this.getEditorKeyDisplay("deleteWordForward");
|
||||
const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart");
|
||||
const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
|
||||
const yank = this.getEditorKeyDisplay("yank");
|
||||
const yankPop = this.getEditorKeyDisplay("yankPop");
|
||||
const tab = this.getEditorKeyDisplay("tab");
|
||||
|
||||
// App keybindings
|
||||
|
|
@ -3469,8 +3472,11 @@ export class InteractiveMode {
|
|||
| \`${submit}\` | Send message |
|
||||
| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} |
|
||||
| \`${deleteWordBackward}\` | Delete word backwards |
|
||||
| \`${deleteWordForward}\` | Delete word forwards |
|
||||
| \`${deleteToLineStart}\` | Delete to start of line |
|
||||
| \`${deleteToLineEnd}\` | Delete to end of line |
|
||||
| \`${yank}\` | Paste the most-recently-deleted text |
|
||||
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
||||
|
||||
**Other**
|
||||
| Key | Action |
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
### Added
|
||||
|
||||
- Added `showHardwareCursor` getter and setter to control cursor visibility while keeping IME positioning active. ([#800](https://github.com/badlogic/pi-mono/pull/800) by [@ghoulr](https://github.com/ghoulr))
|
||||
- Added Emacs-style kill ring editing with yank and yank-pop keybindings. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence))
|
||||
- Added legacy Alt+letter handling and Alt+D delete word forward support in the editor keymap. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence))
|
||||
|
||||
## [0.48.0] - 2026-01-16
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import assert from "node:assert";
|
||||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import { getEditorKeybindings } from "../keybindings.js";
|
||||
import { matchesKey } from "../keys.js";
|
||||
|
|
@ -288,6 +289,10 @@ export class Editor implements Component, Focusable {
|
|||
private history: string[] = [];
|
||||
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
||||
|
||||
// Kill ring for Emacs-style kill/yank operations
|
||||
private killRing: string[] = [];
|
||||
private lastAction: "kill" | "yank" | null = null;
|
||||
|
||||
public onSubmit?: (text: string) => void;
|
||||
public onChange?: (text: string) => void;
|
||||
public disableSubmit: boolean = false;
|
||||
|
|
@ -349,6 +354,7 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
|
||||
private navigateHistory(direction: 1 | -1): void {
|
||||
this.lastAction = null;
|
||||
if (this.history.length === 0) return;
|
||||
|
||||
const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
||||
|
|
@ -366,6 +372,9 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
||||
private setTextInternal(text: string): void {
|
||||
// Reset kill ring state - external text changes break accumulation/yank chains
|
||||
this.lastAction = null;
|
||||
|
||||
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
this.state.lines = lines.length === 0 ? [""] : lines;
|
||||
this.state.cursorLine = this.state.lines.length - 1;
|
||||
|
|
@ -637,6 +646,10 @@ export class Editor implements Component, Focusable {
|
|||
this.deleteWordBackwards();
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "deleteWordForward")) {
|
||||
this.deleteWordForward();
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
||||
this.handleBackspace();
|
||||
return;
|
||||
|
|
@ -646,6 +659,16 @@ export class Editor implements Component, Focusable {
|
|||
return;
|
||||
}
|
||||
|
||||
// Kill ring actions
|
||||
if (kb.matches(data, "yank")) {
|
||||
this.yank();
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "yankPop")) {
|
||||
this.yankPop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cursor movement actions
|
||||
if (kb.matches(data, "cursorLineStart")) {
|
||||
this.moveToLineStart();
|
||||
|
|
@ -886,6 +909,7 @@ export class Editor implements Component, Focusable {
|
|||
// All the editor methods from before...
|
||||
private insertCharacter(char: string): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
|
|
@ -935,6 +959,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
private handlePaste(pastedText: string): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
// Clean the pasted text
|
||||
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
|
@ -1035,6 +1060,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
private addNewLine(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
|
|
@ -1056,6 +1082,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
private handleBackspace(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
if (this.state.cursorCol > 0) {
|
||||
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
||||
|
|
@ -1107,10 +1134,12 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
|
||||
private moveToLineStart(): void {
|
||||
this.lastAction = null;
|
||||
this.state.cursorCol = 0;
|
||||
}
|
||||
|
||||
private moveToLineEnd(): void {
|
||||
this.lastAction = null;
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = currentLine.length;
|
||||
}
|
||||
|
|
@ -1121,11 +1150,19 @@ export class Editor implements Component, Focusable {
|
|||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
if (this.state.cursorCol > 0) {
|
||||
// Calculate text to be deleted and save to kill ring (backward deletion = prepend)
|
||||
const deletedText = currentLine.slice(0, this.state.cursorCol);
|
||||
this.addToKillRing(deletedText, true);
|
||||
this.lastAction = "kill";
|
||||
|
||||
// Delete from start of line up to cursor
|
||||
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
|
||||
this.state.cursorCol = 0;
|
||||
} else if (this.state.cursorLine > 0) {
|
||||
// At start of line - merge with previous line
|
||||
// At start of line - merge with previous line, treating newline as deleted text
|
||||
this.addToKillRing("\n", true);
|
||||
this.lastAction = "kill";
|
||||
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
|
|
@ -1144,10 +1181,18 @@ export class Editor implements Component, Focusable {
|
|||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
if (this.state.cursorCol < currentLine.length) {
|
||||
// Calculate text to be deleted and save to kill ring (forward deletion = append)
|
||||
const deletedText = currentLine.slice(this.state.cursorCol);
|
||||
this.addToKillRing(deletedText, false);
|
||||
this.lastAction = "kill";
|
||||
|
||||
// Delete from cursor to end of line
|
||||
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
|
||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||
// At end of line - merge with next line
|
||||
// At end of line - merge with next line, treating newline as deleted text
|
||||
this.addToKillRing("\n", false);
|
||||
this.lastAction = "kill";
|
||||
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
||||
|
|
@ -1166,6 +1211,10 @@ export class Editor implements Component, Focusable {
|
|||
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
||||
if (this.state.cursorCol === 0) {
|
||||
if (this.state.cursorLine > 0) {
|
||||
// Treat newline as deleted text (backward deletion = prepend)
|
||||
this.addToKillRing("\n", true);
|
||||
this.lastAction = "kill";
|
||||
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
|
|
@ -1173,11 +1222,20 @@ export class Editor implements Component, Focusable {
|
|||
this.state.cursorCol = previousLine.length;
|
||||
}
|
||||
} else {
|
||||
// Save lastAction before cursor movement (moveWordBackwards resets it)
|
||||
const wasKill = this.lastAction === "kill";
|
||||
|
||||
const oldCursorCol = this.state.cursorCol;
|
||||
this.moveWordBackwards();
|
||||
const deleteFrom = this.state.cursorCol;
|
||||
this.state.cursorCol = oldCursorCol;
|
||||
|
||||
// Restore kill state for accumulation check, then save to kill ring
|
||||
this.lastAction = wasKill ? "kill" : null;
|
||||
const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);
|
||||
this.addToKillRing(deletedText, true);
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.state.lines[this.state.cursorLine] =
|
||||
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
|
||||
this.state.cursorCol = deleteFrom;
|
||||
|
|
@ -1188,8 +1246,49 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
}
|
||||
|
||||
private deleteWordForward(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
// If at end of line, merge with next line (delete the newline)
|
||||
if (this.state.cursorCol >= currentLine.length) {
|
||||
if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||
// Treat newline as deleted text (forward deletion = append)
|
||||
this.addToKillRing("\n", false);
|
||||
this.lastAction = "kill";
|
||||
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
||||
}
|
||||
} else {
|
||||
// Save lastAction before cursor movement (moveWordForwards resets it)
|
||||
const wasKill = this.lastAction === "kill";
|
||||
|
||||
const oldCursorCol = this.state.cursorCol;
|
||||
this.moveWordForwards();
|
||||
const deleteTo = this.state.cursorCol;
|
||||
this.state.cursorCol = oldCursorCol;
|
||||
|
||||
// Restore kill state for accumulation check, then save to kill ring
|
||||
this.lastAction = wasKill ? "kill" : null;
|
||||
const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);
|
||||
this.addToKillRing(deletedText, false);
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.state.lines[this.state.cursorLine] =
|
||||
currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private handleForwardDelete(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
|
|
@ -1292,6 +1391,7 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
|
||||
private moveCursor(deltaLine: number, deltaCol: number): void {
|
||||
this.lastAction = null;
|
||||
const width = this.lastWidth;
|
||||
|
||||
if (deltaLine !== 0) {
|
||||
|
|
@ -1355,6 +1455,7 @@ export class Editor implements Component, Focusable {
|
|||
* Moves cursor by the page size while keeping it in bounds.
|
||||
*/
|
||||
private pageScroll(direction: -1 | 1): void {
|
||||
this.lastAction = null;
|
||||
const width = this.lastWidth;
|
||||
const terminalRows = this.tui.terminal.rows;
|
||||
const pageSize = Math.max(5, Math.floor(terminalRows * 0.3));
|
||||
|
|
@ -1381,6 +1482,7 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
|
||||
private moveWordBackwards(): void {
|
||||
this.lastAction = null;
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
// If at start of line, move to end of previous line
|
||||
|
|
@ -1424,7 +1526,150 @@ export class Editor implements Component, Focusable {
|
|||
this.state.cursorCol = newCol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yank (paste) the most recent kill ring entry at cursor position.
|
||||
*/
|
||||
private yank(): void {
|
||||
if (this.killRing.length === 0) return;
|
||||
|
||||
const text = this.killRing[this.killRing.length - 1] || "";
|
||||
this.insertYankedText(text);
|
||||
|
||||
this.lastAction = "yank";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle through kill ring (only works immediately after yank or yank-pop).
|
||||
* Replaces the last yanked text with the previous entry in the ring.
|
||||
*/
|
||||
private yankPop(): void {
|
||||
// Only works if we just yanked and have more than one entry
|
||||
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
|
||||
|
||||
// Delete the previously yanked text (still at end of ring before rotation)
|
||||
this.deleteYankedText();
|
||||
|
||||
// Rotate the ring: move end to front
|
||||
const lastEntry = this.killRing.pop();
|
||||
assert(lastEntry !== undefined); // Since killRing was not empty
|
||||
this.killRing.unshift(lastEntry);
|
||||
|
||||
// Insert the new most recent entry (now at end after rotation)
|
||||
const text = this.killRing[this.killRing.length - 1];
|
||||
this.insertYankedText(text);
|
||||
|
||||
this.lastAction = "yank";
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at cursor position (used by yank operations).
|
||||
*/
|
||||
private insertYankedText(text: string): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
const lines = text.split("\n");
|
||||
|
||||
if (lines.length === 1) {
|
||||
// Single line - insert at cursor
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const before = currentLine.slice(0, this.state.cursorCol);
|
||||
const after = currentLine.slice(this.state.cursorCol);
|
||||
this.state.lines[this.state.cursorLine] = before + text + after;
|
||||
this.state.cursorCol += text.length;
|
||||
} else {
|
||||
// Multi-line insert
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const before = currentLine.slice(0, this.state.cursorCol);
|
||||
const after = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
// First line merges with text before cursor
|
||||
this.state.lines[this.state.cursorLine] = before + (lines[0] || "");
|
||||
|
||||
// Insert middle lines
|
||||
for (let i = 1; i < lines.length - 1; i++) {
|
||||
this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || "");
|
||||
}
|
||||
|
||||
// Last line merges with text after cursor
|
||||
const lastLineIndex = this.state.cursorLine + lines.length - 1;
|
||||
this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after);
|
||||
|
||||
// Update cursor position
|
||||
this.state.cursorLine = lastLineIndex;
|
||||
this.state.cursorCol = (lines[lines.length - 1] || "").length;
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the previously yanked text (used by yank-pop).
|
||||
* The yanked text is derived from killRing[end] since it hasn't been rotated yet.
|
||||
*/
|
||||
private deleteYankedText(): void {
|
||||
const yankedText = this.killRing[this.killRing.length - 1] || "";
|
||||
if (!yankedText) return;
|
||||
|
||||
const yankLines = yankedText.split("\n");
|
||||
|
||||
if (yankLines.length === 1) {
|
||||
// Single line - delete backward from cursor
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const deleteLen = yankedText.length;
|
||||
const before = currentLine.slice(0, this.state.cursorCol - deleteLen);
|
||||
const after = currentLine.slice(this.state.cursorCol);
|
||||
this.state.lines[this.state.cursorLine] = before + after;
|
||||
this.state.cursorCol -= deleteLen;
|
||||
} else {
|
||||
// Multi-line delete - cursor is at end of last yanked line
|
||||
const startLine = this.state.cursorLine - (yankLines.length - 1);
|
||||
const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length;
|
||||
|
||||
// Get text after cursor on current line
|
||||
const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol);
|
||||
|
||||
// Get text before yank start position
|
||||
const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol);
|
||||
|
||||
// Remove all lines from startLine to cursorLine and replace with merged line
|
||||
this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor);
|
||||
|
||||
// Update cursor
|
||||
this.state.cursorLine = startLine;
|
||||
this.state.cursorCol = startCol;
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add text to the kill ring.
|
||||
* If lastAction is "kill", accumulates with the previous entry.
|
||||
* @param text - The text to add
|
||||
* @param prepend - If accumulating, prepend (true) or append (false) to existing entry
|
||||
*/
|
||||
private addToKillRing(text: string, prepend: boolean): void {
|
||||
if (!text) return;
|
||||
|
||||
if (this.lastAction === "kill" && this.killRing.length > 0) {
|
||||
// Accumulate with the most recent entry (at end of array)
|
||||
const lastEntry = this.killRing.pop();
|
||||
if (prepend) {
|
||||
this.killRing.push(text + lastEntry);
|
||||
} else {
|
||||
this.killRing.push(lastEntry + text);
|
||||
}
|
||||
} else {
|
||||
// Add new entry to end of ring
|
||||
this.killRing.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
private moveWordForwards(): void {
|
||||
this.lastAction = null;
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
// If at end of line, move to start of next line
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export type EditorAction =
|
|||
| "deleteCharBackward"
|
||||
| "deleteCharForward"
|
||||
| "deleteWordBackward"
|
||||
| "deleteWordForward"
|
||||
| "deleteToLineStart"
|
||||
| "deleteToLineEnd"
|
||||
// Text input
|
||||
|
|
@ -34,6 +35,9 @@ export type EditorAction =
|
|||
| "selectCancel"
|
||||
// Clipboard
|
||||
| "copy"
|
||||
// Kill ring
|
||||
| "yank"
|
||||
| "yankPop"
|
||||
// Tool output
|
||||
| "expandTools";
|
||||
|
||||
|
|
@ -66,6 +70,7 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
|
|||
deleteCharBackward: "backspace",
|
||||
deleteCharForward: "delete",
|
||||
deleteWordBackward: ["ctrl+w", "alt+backspace"],
|
||||
deleteWordForward: "alt+d",
|
||||
deleteToLineStart: "ctrl+u",
|
||||
deleteToLineEnd: "ctrl+k",
|
||||
// Text input
|
||||
|
|
@ -81,6 +86,9 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
|
|||
selectCancel: ["escape", "ctrl+c"],
|
||||
// Clipboard
|
||||
copy: "ctrl+c",
|
||||
// Kill ring
|
||||
yank: "ctrl+y",
|
||||
yankPop: "alt+y",
|
||||
// Tool output
|
||||
expandTools: "ctrl+o",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -971,6 +971,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|||
return data === `\x1b${rawCtrlChar(key)}`;
|
||||
}
|
||||
|
||||
if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") {
|
||||
// Legacy: alt+letter is ESC followed by the letter
|
||||
if (data === `\x1b${key}`) return true;
|
||||
}
|
||||
|
||||
if (ctrl && !shift && !alt) {
|
||||
const raw = rawCtrlChar(key);
|
||||
if (data === raw) return true;
|
||||
|
|
@ -1073,6 +1078,10 @@ export function parseKey(data: string): string | undefined {
|
|||
if (code >= 1 && code <= 26) {
|
||||
return `ctrl+alt+${String.fromCharCode(code + 96)}`;
|
||||
}
|
||||
// Legacy alt+letter (ESC followed by letter a-z)
|
||||
if (code >= 97 && code <= 122) {
|
||||
return `alt+${String.fromCharCode(code)}`;
|
||||
}
|
||||
}
|
||||
if (data === "\x1b[A") return "up";
|
||||
if (data === "\x1b[B") return "down";
|
||||
|
|
|
|||
|
|
@ -698,4 +698,401 @@ describe("Editor component", () => {
|
|||
assert.ok(contentLine.includes("1234567890"), "Content should contain the word");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kill ring", () => {
|
||||
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("foo bar baz");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "baz"
|
||||
assert.strictEqual(editor.getText(), "foo bar ");
|
||||
|
||||
// Move to beginning and yank
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "bazfoo bar ");
|
||||
});
|
||||
|
||||
it("Ctrl+U saves deleted text to kill ring", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("hello world");
|
||||
// Move cursor to middle
|
||||
editor.handleInput("\x01"); // Ctrl+A (start)
|
||||
editor.handleInput("\x1b[C"); // Right 5 times
|
||||
editor.handleInput("\x1b[C");
|
||||
editor.handleInput("\x1b[C");
|
||||
editor.handleInput("\x1b[C");
|
||||
editor.handleInput("\x1b[C");
|
||||
editor.handleInput("\x1b[C"); // After "hello "
|
||||
|
||||
editor.handleInput("\x15"); // Ctrl+U - deletes "hello "
|
||||
assert.strictEqual(editor.getText(), "world");
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "hello world");
|
||||
});
|
||||
|
||||
it("Ctrl+K saves deleted text to kill ring", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("hello world");
|
||||
editor.handleInput("\x01"); // Ctrl+A (start)
|
||||
editor.handleInput("\x0b"); // Ctrl+K - deletes "hello world"
|
||||
|
||||
assert.strictEqual(editor.getText(), "");
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "hello world");
|
||||
});
|
||||
|
||||
it("Ctrl+Y does nothing when kill ring is empty", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("test");
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "test");
|
||||
});
|
||||
|
||||
it("Alt+Y cycles through kill ring after Ctrl+Y", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
// Create kill ring with multiple entries
|
||||
editor.setText("first");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "first"
|
||||
editor.setText("second");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "second"
|
||||
editor.setText("third");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "third"
|
||||
|
||||
// Kill ring now has: [first, second, third]
|
||||
assert.strictEqual(editor.getText(), "");
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y - yanks "third" (most recent)
|
||||
assert.strictEqual(editor.getText(), "third");
|
||||
|
||||
editor.handleInput("\x1by"); // Alt+Y - cycles to "second"
|
||||
assert.strictEqual(editor.getText(), "second");
|
||||
|
||||
editor.handleInput("\x1by"); // Alt+Y - cycles to "first"
|
||||
assert.strictEqual(editor.getText(), "first");
|
||||
|
||||
editor.handleInput("\x1by"); // Alt+Y - cycles back to "third"
|
||||
assert.strictEqual(editor.getText(), "third");
|
||||
});
|
||||
|
||||
it("Alt+Y does nothing if not preceded by yank", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("test");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "test"
|
||||
editor.setText("other");
|
||||
|
||||
// Type something to break the yank chain
|
||||
editor.handleInput("x");
|
||||
assert.strictEqual(editor.getText(), "otherx");
|
||||
|
||||
// Alt+Y should do nothing
|
||||
editor.handleInput("\x1by"); // Alt+Y
|
||||
assert.strictEqual(editor.getText(), "otherx");
|
||||
});
|
||||
|
||||
it("Alt+Y does nothing if kill ring has ≤1 entry", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("only");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "only"
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y - yanks "only"
|
||||
assert.strictEqual(editor.getText(), "only");
|
||||
|
||||
editor.handleInput("\x1by"); // Alt+Y - should do nothing (only 1 entry)
|
||||
assert.strictEqual(editor.getText(), "only");
|
||||
});
|
||||
|
||||
it("consecutive Ctrl+W accumulates into one kill ring entry", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("one two three");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "three"
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "two " (prepended)
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "one " (prepended)
|
||||
|
||||
assert.strictEqual(editor.getText(), "");
|
||||
|
||||
// Should be one combined entry
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "one two three");
|
||||
});
|
||||
|
||||
it("Ctrl+U accumulates multiline deletes including newlines", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
// Start with multiline text, cursor at end
|
||||
editor.setText("line1\nline2\nline3");
|
||||
// Cursor is at end of line3 (line 2, col 5)
|
||||
|
||||
// Delete "line3"
|
||||
editor.handleInput("\x15"); // Ctrl+U
|
||||
assert.strictEqual(editor.getText(), "line1\nline2\n");
|
||||
|
||||
// Delete newline (at start of empty line 2, merges with line1)
|
||||
editor.handleInput("\x15"); // Ctrl+U
|
||||
assert.strictEqual(editor.getText(), "line1\nline2");
|
||||
|
||||
// Delete "line2"
|
||||
editor.handleInput("\x15"); // Ctrl+U
|
||||
assert.strictEqual(editor.getText(), "line1\n");
|
||||
|
||||
// Delete newline
|
||||
editor.handleInput("\x15"); // Ctrl+U
|
||||
assert.strictEqual(editor.getText(), "line1");
|
||||
|
||||
// Delete "line1"
|
||||
editor.handleInput("\x15"); // Ctrl+U
|
||||
assert.strictEqual(editor.getText(), "");
|
||||
|
||||
// All deletions accumulated into one entry: "line1\nline2\nline3"
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "line1\nline2\nline3");
|
||||
});
|
||||
|
||||
it("backward deletions prepend, forward deletions append during accumulation", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("prefix|suffix");
|
||||
// Position cursor at |
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 times
|
||||
|
||||
editor.handleInput("\x0b"); // Ctrl+K - deletes "suffix" (forward)
|
||||
editor.handleInput("\x0b"); // Ctrl+K - deletes "|" (forward, appended)
|
||||
assert.strictEqual(editor.getText(), "prefix");
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "prefix|suffix");
|
||||
});
|
||||
|
||||
it("non-delete actions break kill accumulation", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
// Delete "baz", then type "x" to break accumulation, then delete "x"
|
||||
editor.setText("foo bar baz");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "baz"
|
||||
assert.strictEqual(editor.getText(), "foo bar ");
|
||||
|
||||
editor.handleInput("x"); // Typing breaks accumulation
|
||||
assert.strictEqual(editor.getText(), "foo bar x");
|
||||
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry, not accumulated)
|
||||
assert.strictEqual(editor.getText(), "foo bar ");
|
||||
|
||||
// Yank most recent - should be "x", not "xbaz"
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "foo bar x");
|
||||
|
||||
// Cycle to previous - should be "baz" (separate entry)
|
||||
editor.handleInput("\x1by"); // Alt+Y
|
||||
assert.strictEqual(editor.getText(), "foo bar baz");
|
||||
});
|
||||
|
||||
it("non-yank actions break Alt+Y chain", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("first");
|
||||
editor.handleInput("\x17"); // Ctrl+W
|
||||
editor.setText("second");
|
||||
editor.handleInput("\x17"); // Ctrl+W
|
||||
editor.setText("");
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y - yanks "second"
|
||||
assert.strictEqual(editor.getText(), "second");
|
||||
|
||||
editor.handleInput("x"); // Type breaks yank chain
|
||||
assert.strictEqual(editor.getText(), "secondx");
|
||||
|
||||
editor.handleInput("\x1by"); // Alt+Y - should do nothing
|
||||
assert.strictEqual(editor.getText(), "secondx");
|
||||
});
|
||||
|
||||
it("kill ring rotation persists after cycling", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("first");
|
||||
editor.handleInput("\x17"); // deletes "first"
|
||||
editor.setText("second");
|
||||
editor.handleInput("\x17"); // deletes "second"
|
||||
editor.setText("third");
|
||||
editor.handleInput("\x17"); // deletes "third"
|
||||
editor.setText("");
|
||||
|
||||
// Ring: [first, second, third]
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y - yanks "third"
|
||||
editor.handleInput("\x1by"); // Alt+Y - cycles to "second", ring rotates
|
||||
|
||||
// Now ring is: [third, first, second]
|
||||
assert.strictEqual(editor.getText(), "second");
|
||||
|
||||
// Do something else
|
||||
editor.handleInput("x");
|
||||
editor.setText("");
|
||||
|
||||
// New yank should get "second" (now at end after rotation)
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "second");
|
||||
});
|
||||
|
||||
it("consecutive deletions across lines coalesce into one entry", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
// "1\n2\n3" with cursor at end, delete everything with Ctrl+W
|
||||
editor.setText("1\n2\n3");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "3"
|
||||
assert.strictEqual(editor.getText(), "1\n2\n");
|
||||
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes newline (merge with prev line)
|
||||
assert.strictEqual(editor.getText(), "1\n2");
|
||||
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "2"
|
||||
assert.strictEqual(editor.getText(), "1\n");
|
||||
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes newline
|
||||
assert.strictEqual(editor.getText(), "1");
|
||||
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "1"
|
||||
assert.strictEqual(editor.getText(), "");
|
||||
|
||||
// All deletions should have accumulated into one entry
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "1\n2\n3");
|
||||
});
|
||||
|
||||
it("Ctrl+K at line end deletes newline and coalesces", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
// "ab" on line 1, "cd" on line 2, cursor at end of line 1
|
||||
editor.setText("");
|
||||
editor.handleInput("a");
|
||||
editor.handleInput("b");
|
||||
editor.handleInput("\n");
|
||||
editor.handleInput("c");
|
||||
editor.handleInput("d");
|
||||
// Move to end of first line
|
||||
editor.handleInput("\x1b[A"); // Up arrow
|
||||
editor.handleInput("\x05"); // Ctrl+E - end of line
|
||||
|
||||
// Now at end of "ab", Ctrl+K should delete newline (merge with "cd")
|
||||
editor.handleInput("\x0b"); // Ctrl+K - deletes newline
|
||||
assert.strictEqual(editor.getText(), "abcd");
|
||||
|
||||
// Continue deleting
|
||||
editor.handleInput("\x0b"); // Ctrl+K - deletes "cd"
|
||||
assert.strictEqual(editor.getText(), "ab");
|
||||
|
||||
// Both deletions should accumulate
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "ab\ncd");
|
||||
});
|
||||
|
||||
it("handles yank in middle of text", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("word");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "word"
|
||||
editor.setText("hello world");
|
||||
|
||||
// Move to middle (after "hello ")
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C");
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "hello wordworld");
|
||||
});
|
||||
|
||||
it("handles yank-pop in middle of text", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
// Create two kill ring entries
|
||||
editor.setText("FIRST");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "FIRST"
|
||||
editor.setText("SECOND");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "SECOND"
|
||||
|
||||
// Ring: ["FIRST", "SECOND"]
|
||||
|
||||
// Set up "hello world" and position cursor after "hello "
|
||||
editor.setText("hello world");
|
||||
editor.handleInput("\x01"); // Ctrl+A - go to start of line
|
||||
for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6
|
||||
|
||||
// Yank "SECOND" in the middle
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "hello SECONDworld");
|
||||
|
||||
// Yank-pop replaces "SECOND" with "FIRST"
|
||||
editor.handleInput("\x1by"); // Alt+Y
|
||||
assert.strictEqual(editor.getText(), "hello FIRSTworld");
|
||||
});
|
||||
|
||||
it("multiline yank and yank-pop in middle of text", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
// Create single-line entry
|
||||
editor.setText("SINGLE");
|
||||
editor.handleInput("\x17"); // Ctrl+W - deletes "SINGLE"
|
||||
|
||||
// Create multiline entry via consecutive Ctrl+U
|
||||
editor.setText("A\nB");
|
||||
editor.handleInput("\x15"); // Ctrl+U - deletes "B"
|
||||
editor.handleInput("\x15"); // Ctrl+U - deletes newline
|
||||
editor.handleInput("\x15"); // Ctrl+U - deletes "A"
|
||||
// Ring: ["SINGLE", "A\nB"]
|
||||
|
||||
// Insert in middle of "hello world"
|
||||
editor.setText("hello world");
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C");
|
||||
|
||||
// Yank multiline "A\nB"
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "hello A\nBworld");
|
||||
|
||||
// Yank-pop replaces with "SINGLE"
|
||||
editor.handleInput("\x1by"); // Alt+Y
|
||||
assert.strictEqual(editor.getText(), "hello SINGLEworld");
|
||||
});
|
||||
|
||||
it("Alt+D deletes word forward and saves to kill ring", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("hello world test");
|
||||
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||
|
||||
editor.handleInput("\x1bd"); // Alt+D - deletes "hello"
|
||||
assert.strictEqual(editor.getText(), " world test");
|
||||
|
||||
editor.handleInput("\x1bd"); // Alt+D - deletes " world" (skips whitespace, then word)
|
||||
assert.strictEqual(editor.getText(), " test");
|
||||
|
||||
// Yank should get accumulated text
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "hello world test");
|
||||
});
|
||||
|
||||
it("Alt+D at end of line deletes newline", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("line1\nline2");
|
||||
// Move to start of document, then to end of first line
|
||||
editor.handleInput("\x1b[A"); // Up arrow - go to first line
|
||||
editor.handleInput("\x05"); // Ctrl+E - end of line
|
||||
|
||||
editor.handleInput("\x1bd"); // Alt+D - deletes newline (merges lines)
|
||||
assert.strictEqual(editor.getText(), "line1line2");
|
||||
|
||||
editor.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(editor.getText(), "line1\nline2");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,6 +149,12 @@ describe("matchesKey", () => {
|
|||
assert.strictEqual(parseKey("\x1bB"), "alt+left");
|
||||
assert.strictEqual(matchesKey("\x1bF", "alt+right"), true);
|
||||
assert.strictEqual(parseKey("\x1bF"), "alt+right");
|
||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
|
||||
assert.strictEqual(parseKey("\x1ba"), "alt+a");
|
||||
assert.strictEqual(matchesKey("\x1by", "alt+y"), true);
|
||||
assert.strictEqual(parseKey("\x1by"), "alt+y");
|
||||
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
|
||||
assert.strictEqual(parseKey("\x1bz"), "alt+z");
|
||||
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b ", "alt+space"), false);
|
||||
|
|
@ -161,6 +167,10 @@ describe("matchesKey", () => {
|
|||
assert.strictEqual(parseKey("\x1bB"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1bF", "alt+right"), false);
|
||||
assert.strictEqual(parseKey("\x1bF"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
|
||||
assert.strictEqual(parseKey("\x1ba"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1by", "alt+y"), false);
|
||||
assert.strictEqual(parseKey("\x1by"), undefined);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue