Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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