feat(tui): add character jump navigation (Ctrl+], Ctrl+Alt+]) (#1074)

Add Bash/Readline-style character search:

- Ctrl+] enters forward jump mode, awaits next character, jumps to it
- Ctrl+Alt+] does the same but searches backward
- Multi-line search
- Case-sensitive matching
- Pressing the hotkey again cancels; control chars cancel and fall
  through
This commit is contained in:
Sviatoslav Abakumov 2026-01-30 04:42:14 +04:00 committed by GitHub
parent e20583aac8
commit c5d16fe456
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 304 additions and 1 deletions

View file

@ -196,6 +196,9 @@ export class Editor implements Component, Focusable {
private killRing: string[] = [];
private lastAction: "kill" | "yank" | "type-word" | null = null;
// Character jump mode
private jumpMode: "forward" | "backward" | null = null;
// Undo support
private undoStack: EditorState[] = [];
@ -437,6 +440,26 @@ export class Editor implements Component, Focusable {
handleInput(data: string): void {
const kb = getEditorKeybindings();
// Handle character jump mode (awaiting next character to jump to)
if (this.jumpMode !== null) {
// Cancel if the hotkey is pressed again
if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
this.jumpMode = null;
return;
}
if (data.charCodeAt(0) >= 32) {
// Printable character - perform the jump
const direction = this.jumpMode;
this.jumpMode = null;
this.jumpToChar(data, direction);
return;
}
// Control character - cancel and fall through to normal handling
this.jumpMode = null;
}
// Handle bracketed paste mode
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
@ -678,6 +701,16 @@ export class Editor implements Component, Focusable {
return;
}
// Character jump mode triggers
if (kb.matches(data, "jumpForward")) {
this.jumpMode = "forward";
return;
}
if (kb.matches(data, "jumpBackward")) {
this.jumpMode = "backward";
return;
}
// Shift+Space - insert regular space
if (matchesKey(data, "shift+space")) {
this.insertCharacter(" ");
@ -1664,6 +1697,40 @@ export class Editor implements Component, Focusable {
}
}
/**
* Jump to the first occurrence of a character in the specified direction.
* Multi-line search. Case-sensitive. Skips the current cursor position.
*/
private jumpToChar(char: string, direction: "forward" | "backward"): void {
this.lastAction = null;
const isForward = direction === "forward";
const lines = this.state.lines;
const end = isForward ? lines.length : -1;
const step = isForward ? 1 : -1;
for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
const line = lines[lineIdx] || "";
const isCurrentLine = lineIdx === this.state.cursorLine;
// Current line: start after/before cursor; other lines: search full line
const searchFrom = isCurrentLine
? isForward
? this.state.cursorCol + 1
: this.state.cursorCol - 1
: undefined;
const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
if (idx !== -1) {
this.state.cursorLine = lineIdx;
this.state.cursorCol = idx;
return;
}
}
// No match found - cursor stays in place
}
private moveWordForwards(): void {
this.lastAction = null;
const currentLine = this.state.lines[this.state.cursorLine] || "";

View file

@ -13,6 +13,8 @@ export type EditorAction =
| "cursorWordRight"
| "cursorLineStart"
| "cursorLineEnd"
| "jumpForward"
| "jumpBackward"
| "pageUp"
| "pageDown"
// Deletion
@ -72,6 +74,8 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
cursorLineStart: ["home", "ctrl+a"],
cursorLineEnd: ["end", "ctrl+e"],
jumpForward: "ctrl+]",
jumpBackward: "ctrl+alt+]",
pageUp: "pageUp",
pageDown: "pageDown",
// Deletion