diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index c5db1c51fd..49c9fb8b5c 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -27,6 +27,11 @@ import { } from '../utils/terminal-enter-detection' import { supportsTruecolor } from '../utils/theme-system' import { calculateNewCursorPosition } from '../utils/word-wrap-utils' +import { + findNextWordBoundary, + findPreviousWordBoundary, + getWordNavigationPosition, +} from '../utils/word-navigation' import type { InputValue } from '../types/store' import type { @@ -59,38 +64,6 @@ function findLineEnd(text: string, cursor: number): number { return pos } -function findPreviousWordBoundary(text: string, cursor: number): number { - let pos = Math.max(0, Math.min(cursor, text.length)) - - // Skip whitespace backwards - while (pos > 0 && /\s/.test(text[pos - 1])) { - pos-- - } - - // Skip word characters backwards - while (pos > 0 && !/\s/.test(text[pos - 1])) { - pos-- - } - - return pos -} - -function findNextWordBoundary(text: string, cursor: number): number { - let pos = Math.max(0, Math.min(cursor, text.length)) - - // Skip non-whitespace forwards - while (pos < text.length && !/\s/.test(text[pos])) { - pos++ - } - - // Skip whitespace forwards - while (pos < text.length && /\s/.test(text[pos])) { - pos++ - } - - return pos -} - export const CURSOR_CHAR = '▍' const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f]/ const TAB_WIDTH = 4 @@ -836,8 +809,6 @@ export const MultilineInput = forwardRef< const isAltLikeModifier = isAltModifier(key) const logicalLineStart = findLineStart(value, cursorPosition) const logicalLineEnd = findLineEnd(value, cursorPosition) - const wordStart = findPreviousWordBoundary(value, cursorPosition) - const wordEnd = findNextWordBoundary(value, cursorPosition) // Read lineInfo inside the callback to get current value (not stale from closure) const currentLineInfo = textRef.current @@ -857,29 +828,18 @@ export const MultilineInput = forwardRef< ? lineStarts[visualLineIndex + 1] - 1 : logicalLineEnd - // Alt+Left/B: Word left - if ( - isAltLikeModifier && - (key.name === 'left' || lowerKeyName === 'b') - ) { - preventKeyDefault(key) - onChange({ - text: value, - cursorPosition: wordStart, - lastEditDueToNav: false, - }) - return true - } - - // Alt+Right/F: Word right - if ( - isAltLikeModifier && - (key.name === 'right' || lowerKeyName === 'f') - ) { + // Alt+Left/Right (or B/F), plus Ctrl+Left/Right on Windows: word-wise movement. + const wordNavigationPosition = getWordNavigationPosition( + key, + value, + cursorPosition, + isAltLikeModifier, + ) + if (wordNavigationPosition !== null) { preventKeyDefault(key) onChange({ text: value, - cursorPosition: wordEnd, + cursorPosition: wordNavigationPosition, lastEditDueToNav: false, }) return true diff --git a/cli/src/utils/__tests__/word-navigation.test.ts b/cli/src/utils/__tests__/word-navigation.test.ts new file mode 100644 index 0000000000..4173675049 --- /dev/null +++ b/cli/src/utils/__tests__/word-navigation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' + +import { getWordNavigationPosition } from '../word-navigation' + +describe('word navigation', () => { + const text = 'one two three' + + test('moves to the previous word with Ctrl+Left', () => { + expect( + getWordNavigationPosition( + { name: 'left', ctrl: true }, + text, + text.length, + false, + ), + ).toBe(8) + }) + + test('moves to the next word with Ctrl+Right', () => { + expect( + getWordNavigationPosition({ name: 'right', ctrl: true }, text, 0, false), + ).toBe(4) + }) + + test('keeps Alt word navigation working', () => { + expect( + getWordNavigationPosition( + { name: 'left', option: true }, + text, + text.length, + true, + ), + ).toBe(8) + expect( + getWordNavigationPosition({ name: 'f', option: true }, text, 0, true), + ).toBe(4) + }) + + test('does not treat modified arrows as word navigation', () => { + expect( + getWordNavigationPosition( + { name: 'left', ctrl: true, meta: true }, + text, + text.length, + false, + ), + ).toBeNull() + expect( + getWordNavigationPosition({ name: 'right' }, text, 0, false), + ).toBeNull() + }) + + test('leaves Ctrl+B and Ctrl+F for single-character Emacs movement', () => { + expect( + getWordNavigationPosition( + { name: 'b', ctrl: true }, + text, + text.length, + false, + ), + ).toBeNull() + expect( + getWordNavigationPosition({ name: 'f', ctrl: true }, text, 0, false), + ).toBeNull() + }) +}) diff --git a/cli/src/utils/word-navigation.ts b/cli/src/utils/word-navigation.ts new file mode 100644 index 0000000000..4fefd4f994 --- /dev/null +++ b/cli/src/utils/word-navigation.ts @@ -0,0 +1,70 @@ +type WordNavigationKey = { + name?: string + ctrl?: boolean + meta?: boolean + option?: boolean +} + +export function findPreviousWordBoundary(text: string, cursor: number): number { + let position = Math.max(0, Math.min(cursor, text.length)) + + // Skip whitespace backwards, then the word immediately before the cursor. + while (position > 0 && /\s/.test(text[position - 1]!)) { + position-- + } + while (position > 0 && !/\s/.test(text[position - 1]!)) { + position-- + } + + return position +} + +export function findNextWordBoundary(text: string, cursor: number): number { + let position = Math.max(0, Math.min(cursor, text.length)) + + // Skip the word at the cursor, then whitespace before the next word. + while (position < text.length && !/\s/.test(text[position]!)) { + position++ + } + while (position < text.length && /\s/.test(text[position]!)) { + position++ + } + + return position +} + +/** + * Resolve word-wise cursor movement for the conventions used by the input. + * + * Alt+Left/Right (and Alt+B/F) are supported on Unix-like terminals. Windows + * terminals conventionally send Ctrl+Left/Right instead, so keep both paths + * on the same boundary implementation. + */ +export function getWordNavigationPosition( + key: WordNavigationKey, + text: string, + cursor: number, + isAltLikeModifier: boolean, +): number | null { + const lowerKeyName = (key.name ?? '').toLowerCase() + // Keep Ctrl+Arrow exclusive to a plain Ctrl modifier. OpenTUI can expose + // Option/Alt separately (and terminals may encode Alt-like chords with more + // than one modifier bit); those chords belong to the existing Alt path and + // must not be reclassified as Windows-style Ctrl+Arrow navigation. + const isCtrlArrow = key.ctrl && !key.meta && !key.option + + if ( + (isAltLikeModifier && (key.name === 'left' || lowerKeyName === 'b')) || + (isCtrlArrow && key.name === 'left') + ) { + return findPreviousWordBoundary(text, cursor) + } + if ( + (isAltLikeModifier && (key.name === 'right' || lowerKeyName === 'f')) || + (isCtrlArrow && key.name === 'right') + ) { + return findNextWordBoundary(text, cursor) + } + + return null +}