diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 99393dd6..c13a507d 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -27,6 +27,8 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, etc. | `cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right | | `cursorLineStart` | `home`, `ctrl+a` | Move to line start | | `cursorLineEnd` | `end`, `ctrl+e` | Move to line end | +| `jumpForward` | `ctrl+]` | Jump forward to character | +| `jumpBackward` | `ctrl+alt+]` | Jump backward to character | | `pageUp` | `pageUp` | Scroll up by page | | `pageDown` | `pageDown` | Scroll down by page | diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f788c9c6..f156643a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3951,6 +3951,8 @@ export class InteractiveMode { const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight"); const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart"); const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd"); + const jumpForward = this.getEditorKeyDisplay("jumpForward"); + const jumpBackward = this.getEditorKeyDisplay("jumpBackward"); const pageUp = this.getEditorKeyDisplay("pageUp"); const pageDown = this.getEditorKeyDisplay("pageDown"); @@ -3988,6 +3990,8 @@ export class InteractiveMode { | \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | | \`${cursorLineStart}\` | Start of line | | \`${cursorLineEnd}\` | End of line | +| \`${jumpForward}\` | Jump forward to character | +| \`${jumpBackward}\` | Jump backward to character | | \`${pageUp}\` / \`${pageDown}\` | Scroll by page | **Editing** diff --git a/packages/tui/README.md b/packages/tui/README.md index accde46a..96fff9c0 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -298,8 +298,13 @@ editor.getPaddingX(); // Get current padding - `Enter` - Submit - `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable) - `Tab` - Autocomplete -- `Ctrl+K` - Delete line +- `Ctrl+K` - Delete to end of line +- `Ctrl+U` - Delete to start of line +- `Ctrl+W` or `Alt+Backspace` - Delete word backwards +- `Alt+D` or `Alt+Delete` - Delete word forwards - `Ctrl+A` / `Ctrl+E` - Line start/end +- `Ctrl+]` - Jump forward to character (awaits next keypress, then moves cursor to first occurrence) +- `Ctrl+Alt+]` - Jump backward to character - Arrow keys, Backspace, Delete work as expected ### Markdown diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 60f0a229..dc6cd710 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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] || ""; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 2fbede96..b9199e77 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -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 = { 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 diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 9dee4de3..45b7dbeb 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1965,4 +1965,225 @@ describe("Editor component", () => { assert.strictEqual(editor.isShowingAutocomplete(), false); }); }); + + describe("Character jump (Ctrl+])", () => { + it("jumps forward to first occurrence of character on same line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] (legacy sequence for ctrl+]) + editor.handleInput("o"); // Jump to first 'o' + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); // 'o' in "hello" + }); + + it("jumps forward to next occurrence after cursor", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + // Move cursor to the 'o' in "hello" (col 4) + for (let i = 0; i < 4; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("o"); // Jump to next 'o' (in "world") + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // 'o' in "world" + }); + + it("jumps forward across multiple lines", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("abc\ndef\nghi"); + // Cursor is at end (line 2, col 3). Move to line 0 via up arrows, then Ctrl+A + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - now on line 0 + editor.handleInput("\x01"); // Ctrl+A - go to start of line + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("g"); // Jump to 'g' on line 3 + + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 0 }); + }); + + it("jumps backward to first occurrence before cursor on same line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Cursor at end (col 11) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] (ESC followed by Ctrl+]) + editor.handleInput("o"); // Jump to last 'o' before cursor + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // 'o' in "world" + }); + + it("jumps backward across multiple lines", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("abc\ndef\nghi"); + // Cursor at end of line 3 + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 3 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] + editor.handleInput("a"); // Jump to 'a' on line 1 + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("does nothing when character is not found (forward)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("z"); // 'z' doesn't exist + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // Cursor unchanged + }); + + it("does nothing when character is not found (backward)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Cursor at end + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] + editor.handleInput("z"); // 'z' doesn't exist + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); // Cursor unchanged + }); + + it("is case-sensitive", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("Hello World"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Search for lowercase 'h' - should not find it (only 'H' exists) + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("h"); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // Cursor unchanged + + // Search for uppercase 'W' - should find it + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("W"); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // 'W' in "World" + }); + + it("cancels jump mode when Ctrl+] is pressed again", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] - enter jump mode + editor.handleInput("\x1d"); // Ctrl+] again - cancel + + // Type 'o' normally - should insert, not jump + editor.handleInput("o"); + assert.strictEqual(editor.getText(), "ohello world"); + }); + + it("cancels jump mode on Escape and processes the Escape", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] - enter jump mode + editor.handleInput("\x1b"); // Escape - cancel jump mode + + // Cursor should be unchanged (Escape itself doesn't move cursor in editor) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Type 'o' normally - should insert, not jump + editor.handleInput("o"); + assert.strictEqual(editor.getText(), "ohello world"); + }); + + it("cancels backward jump mode when Ctrl+Alt+] is pressed again", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Cursor at end + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] - enter backward jump mode + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] again - cancel + + // Type 'o' normally - should insert, not jump + editor.handleInput("o"); + assert.strictEqual(editor.getText(), "hello worldo"); + }); + + it("searches for special characters", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("foo(bar) = baz;"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Jump to '(' + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("("); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + // Jump to '=' + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("="); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 9 }); + }); + + it("handles empty text gracefully", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText(""); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("x"); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // Cursor unchanged + }); + + it("resets lastAction when jumping", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + + // Type to set lastAction to "type-word" + editor.handleInput("x"); + assert.strictEqual(editor.getText(), "xhello world"); + + // Jump forward + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("o"); + + // Type more - should start a new undo unit (lastAction was reset) + editor.handleInput("Y"); + assert.strictEqual(editor.getText(), "xhellYo world"); + + // Undo should only undo "Y", not "x" as well + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "xhello world"); + }); + }); });