mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 01:04:36 +00:00
feat(tui): implement Emacs-style kill ring for Editor
Add kill ring functionality with: - Ctrl+W/U/K save deleted text to kill ring - Ctrl+Y yanks (pastes) most recent deletion - Alt+Y cycles through kill ring (after Ctrl+Y) - Consecutive deletions accumulate into single entry
This commit is contained in:
parent
bafddc27ed
commit
9fb7434a06
4 changed files with 577 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
@ -646,6 +655,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 +905,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 +955,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 +1056,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 +1078,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 +1130,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 +1146,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 +1177,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 +1207,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 +1218,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;
|
||||
|
|
@ -1190,6 +1244,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
private handleForwardDelete(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
|
|
@ -1292,6 +1347,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 +1411,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 +1438,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 +1482,149 @@ 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 {
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue