Skip to content
68 changes: 14 additions & 54 deletions cli/src/components/multiline-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions cli/src/utils/__tests__/word-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
70 changes: 70 additions & 0 deletions cli/src/utils/word-navigation.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading