diff --git a/cli/src/components/__tests__/multiline-input.test.tsx b/cli/src/components/__tests__/multiline-input.test.tsx index 7fcf7eaa17..c3f8a8d5ea 100644 --- a/cli/src/components/__tests__/multiline-input.test.tsx +++ b/cli/src/components/__tests__/multiline-input.test.tsx @@ -1,9 +1,17 @@ +import '../../../../sdk/test/setup-env' + import { describe, test, expect } from 'bun:test' import { getKeypadPrintableSequence, isKeypadEnter, } from '../../utils/keypad-keys' +import { + findLineStart, + findLineEnd, + findPreviousWordBoundary, + findNextWordBoundary, +} from '../multiline-input' /** * Tests for tab character cursor rendering in MultilineInput component. @@ -656,9 +664,9 @@ describe('MultilineInput - newline keyboard shortcuts', () => { const ESC = '\x1b' return Boolean( key.option || - (key.sequence?.length === 2 && - key.sequence[0] === ESC && - key.sequence[1] !== '['), + (key.sequence?.length === 2 && + key.sequence[0] === ESC && + key.sequence[1] !== '['), ) } @@ -689,10 +697,7 @@ describe('MultilineInput - newline keyboard shortcuts', () => { // So we detect it by checking for name === 'linefeed' rather than ctrl + j const isCtrlJ = lowerKeyName === 'linefeed' || - (key.ctrl && - !key.meta && - !key.option && - lowerKeyName === 'j') + (key.ctrl && !key.meta && !key.option && lowerKeyName === 'j') // Only handle Enter and Ctrl+J here if (!isEnterKey && !isCtrlJ) return 'ignore' @@ -1130,3 +1135,70 @@ describe('MultilineInput - newline keyboard shortcuts', () => { expect(isAltModifier({ option: false })).toBe(false) }) }) + +describe('MultilineInput - word and line boundary operations', () => { + test('finds correct word boundaries backward and forward on standard text', () => { + const text = 'hello world from freebuff' + const cursor = text.length + + const wordStart = findPreviousWordBoundary(text, cursor) + expect(wordStart).toBe(17) // starts at 'freebuff' + + const wordStart2 = findPreviousWordBoundary(text, wordStart) + expect(wordStart2).toBe(12) // starts at 'from' + + const nextWord = findNextWordBoundary(text, 0) + expect(nextWord).toBe(6) // starts at 'world' + + const nextWord2 = findNextWordBoundary(text, nextWord) + expect(nextWord2).toBe(12) // starts at 'from' + }) + + test('finds word boundaries with multiple consecutive whitespace characters', () => { + const text = 'hello \t\n world' + expect(findPreviousWordBoundary(text, text.length)).toBe(13) // start of 'world' + expect(findPreviousWordBoundary(text, 13)).toBe(0) // start of 'hello' + expect(findNextWordBoundary(text, 0)).toBe(13) // start of 'world' + expect(findNextWordBoundary(text, 13)).toBe(text.length) // end of 'world' + }) + + test('handles word boundaries at text edges and empty strings', () => { + expect(findPreviousWordBoundary('', 0)).toBe(0) + expect(findNextWordBoundary('', 0)).toBe(0) + expect(findPreviousWordBoundary('word', 0)).toBe(0) + expect(findNextWordBoundary('word', 4)).toBe(4) + + // Out-of-bounds cursor clamping + expect(findPreviousWordBoundary('word', -5)).toBe(0) + expect(findNextWordBoundary('word', 100)).toBe(4) + }) + + test('finds correct line boundaries for multiline input across lines', () => { + const text = 'line one\nline two\nline three' + const midCursor = 13 // in 'line two' + + expect(findLineStart(text, midCursor)).toBe(9) + expect(findLineEnd(text, midCursor)).toBe(17) + + // First line boundaries + expect(findLineStart(text, 2)).toBe(0) + expect(findLineEnd(text, 2)).toBe(8) + + // Last line boundaries + expect(findLineStart(text, 20)).toBe(18) + expect(findLineEnd(text, 20)).toBe(text.length) + }) + + test('handles line boundaries on empty lines and edge boundaries', () => { + const emptyLineText = 'first\n\nthird' + // Position 6 is the empty line between the two newlines + expect(findLineStart(emptyLineText, 6)).toBe(6) + expect(findLineEnd(emptyLineText, 6)).toBe(6) + + // Edge clamping + expect(findLineStart('', 0)).toBe(0) + expect(findLineEnd('', 0)).toBe(0) + expect(findLineStart('abc', -1)).toBe(0) + expect(findLineEnd('abc', 50)).toBe(3) + }) +}) diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index c5db1c51fd..b1d3f13cd0 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -16,10 +16,7 @@ import { import { InputCursor } from './input-cursor' import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' -import { - getKeypadPrintableSequence, - isKeypadEnter, -} from '../utils/keypad-keys' +import { getKeypadPrintableSequence, isKeypadEnter } from '../utils/keypad-keys' import { clamp } from '../utils/math' import { isLinefeedActingAsEnter, @@ -43,7 +40,7 @@ function getPasteText(event: PasteEvent): string { } // Helper functions for text manipulation -function findLineStart(text: string, cursor: number): number { +export function findLineStart(text: string, cursor: number): number { let pos = Math.max(0, Math.min(cursor, text.length)) while (pos > 0 && text[pos - 1] !== '\n') { pos-- @@ -51,7 +48,7 @@ function findLineStart(text: string, cursor: number): number { return pos } -function findLineEnd(text: string, cursor: number): number { +export function findLineEnd(text: string, cursor: number): number { let pos = Math.max(0, Math.min(cursor, text.length)) while (pos < text.length && text[pos] !== '\n') { pos++ @@ -59,7 +56,7 @@ function findLineEnd(text: string, cursor: number): number { return pos } -function findPreviousWordBoundary(text: string, cursor: number): number { +export function findPreviousWordBoundary(text: string, cursor: number): number { let pos = Math.max(0, Math.min(cursor, text.length)) // Skip whitespace backwards @@ -75,7 +72,7 @@ function findPreviousWordBoundary(text: string, cursor: number): number { return pos } -function findNextWordBoundary(text: string, cursor: number): number { +export function findNextWordBoundary(text: string, cursor: number): number { let pos = Math.max(0, Math.min(cursor, text.length)) // Skip non-whitespace forwards @@ -166,9 +163,9 @@ function isAltModifier(key: KeyEvent): boolean { const ESC = '\x1b' return Boolean( key.option || - (key.sequence?.length === 2 && - key.sequence[0] === ESC && - key.sequence[1] !== '['), + (key.sequence?.length === 2 && + key.sequence[0] === ESC && + key.sequence[1] !== '['), ) } @@ -280,9 +277,9 @@ export const MultilineInput = forwardRef< const prevFocusedRef = useRef(false) useEffect(() => { if (focused && !prevFocusedRef.current) { - (scrollBoxRef.current as FocusableScrollBox | null)?.focus?.() + ;(scrollBoxRef.current as FocusableScrollBox | null)?.focus?.() } else if (!focused && prevFocusedRef.current) { - (scrollBoxRef.current as FocusableScrollBox | null)?.blur?.() + ;(scrollBoxRef.current as FocusableScrollBox | null)?.blur?.() } prevFocusedRef.current = focused }, [focused]) @@ -292,10 +289,10 @@ export const MultilineInput = forwardRef< forwardedRef, () => ({ focus: () => { - (scrollBoxRef.current as FocusableScrollBox | null)?.focus?.() + ;(scrollBoxRef.current as FocusableScrollBox | null)?.focus?.() }, blur: () => { - (scrollBoxRef.current as FocusableScrollBox | null)?.blur?.() + ;(scrollBoxRef.current as FocusableScrollBox | null)?.blur?.() }, }), [], @@ -325,7 +322,10 @@ export const MultilineInput = forwardRef< }, [scrollBoxRef.current, cursorPosition, focused, cursorRow]) // Helper to get current selection in original text coordinates - const getSelectionRange = useCallback((): { start: number; end: number } | null => { + const getSelectionRange = useCallback((): { + start: number + end: number + } | null => { const textBufferView = (textRef.current as any)?.textBufferView if (!textBufferView?.hasSelection?.() || !textBufferView?.getSelection) { return null @@ -334,8 +334,14 @@ export const MultilineInput = forwardRef< if (!selection) return null // Convert from render positions to original text positions - const start = renderPositionToOriginal(value, Math.min(selection.start, selection.end)) - const end = renderPositionToOriginal(value, Math.max(selection.start, selection.end)) + const start = renderPositionToOriginal( + value, + Math.min(selection.start, selection.end), + ) + const end = renderPositionToOriginal( + value, + Math.max(selection.start, selection.end), + ) if (start === end) return null return { start, end } @@ -348,11 +354,15 @@ export const MultilineInput = forwardRef< }, [renderer]) // Helper to delete selected text and return new value and cursor position - const deleteSelection = useCallback((): { newValue: string; newCursor: number } | null => { + const deleteSelection = useCallback((): { + newValue: string + newCursor: number + } | null => { const selection = getSelectionRange() if (!selection) return null - const newValue = value.slice(0, selection.start) + value.slice(selection.end) + const newValue = + value.slice(0, selection.start) + value.slice(selection.end) clearSelection() return { newValue, newCursor: selection.start } }, [value, getSelectionRange, clearSelection]) @@ -462,10 +472,7 @@ export const MultilineInput = forwardRef< const clickRow = clickRowInViewport + scrollPosition // Find which visual line was clicked - const lineIndex = Math.min( - Math.max(0, clickRow), - lineStarts.length - 1, - ) + const lineIndex = Math.min(Math.max(0, clickRow), lineStarts.length - 1) // Get the character range for this line const lineStartChar = lineStarts[lineIndex] @@ -559,15 +566,13 @@ export const MultilineInput = forwardRef< markReturnKeySeenForKey(key) - const linefeedIsEnter = lowerKeyName === 'linefeed' && isLinefeedActingAsEnter() + const linefeedIsEnter = + lowerKeyName === 'linefeed' && isLinefeedActingAsEnter() const isEnterKey = isReturnOrEnter || linefeedIsEnter const isCtrlJ = (lowerKeyName === 'linefeed' && !linefeedIsEnter) || - (key.ctrl && - !key.meta && - !key.option && - lowerKeyName === 'j') + (key.ctrl && !key.meta && !key.option && lowerKeyName === 'j') // Only handle Enter and Ctrl+J here if (!isEnterKey && !isCtrlJ) return false @@ -652,16 +657,14 @@ export const MultilineInput = forwardRef< (key: KeyEvent): boolean => { const lowerKeyName = (key.name ?? '').toLowerCase() const isAltLikeModifier = isAltModifier(key) - const lineStart = findLineStart(value, cursorPosition) - const lineEnd = findLineEnd(value, cursorPosition) - const wordStart = findPreviousWordBoundary(value, cursorPosition) - const wordEnd = findNextWordBoundary(value, cursorPosition) // Ctrl+U: Delete from cursor to beginning of current VISUAL line if (key.ctrl && lowerKeyName === 'u' && !key.meta && !key.option) { preventKeyDefault(key) if (handleSelectionDeletion()) return true - const visualLineStart = lineInfo?.lineStartCols?.[cursorRow] ?? lineStart + const lineStart = findLineStart(value, cursorPosition) + const visualLineStart = + lineInfo?.lineStartCols?.[cursorRow] ?? lineStart if (cursorPosition > visualLineStart) { const newValue = @@ -690,8 +693,8 @@ export const MultilineInput = forwardRef< ) { preventKeyDefault(key) if (handleSelectionDeletion()) return true - const newValue = - value.slice(0, wordStart) + value.slice(cursorPosition) + const wordStart = findPreviousWordBoundary(value, cursorPosition) + const newValue = value.slice(0, wordStart) + value.slice(cursorPosition) onChange({ text: newValue, cursorPosition: wordStart, @@ -704,6 +707,7 @@ export const MultilineInput = forwardRef< if (key.name === 'delete' && key.meta && !isAltLikeModifier) { preventKeyDefault(key) if (handleSelectionDeletion()) return true + const lineStart = findLineStart(value, cursorPosition) const originalValue = value let newValue = originalValue let nextCursor = cursorPosition @@ -742,6 +746,7 @@ export const MultilineInput = forwardRef< if (key.name === 'delete' && isAltLikeModifier) { preventKeyDefault(key) if (handleSelectionDeletion()) return true + const wordEnd = findNextWordBoundary(value, cursorPosition) const newValue = value.slice(0, cursorPosition) + value.slice(wordEnd) onChange({ text: newValue, @@ -755,6 +760,7 @@ export const MultilineInput = forwardRef< if (key.ctrl && lowerKeyName === 'k' && !key.meta && !key.option) { preventKeyDefault(key) if (handleSelectionDeletion()) return true + const lineEnd = findLineEnd(value, cursorPosition) const newValue = value.slice(0, cursorPosition) + value.slice(lineEnd) onChange({ text: newValue, cursorPosition, lastEditDueToNav: false }) return true @@ -826,7 +832,14 @@ export const MultilineInput = forwardRef< return false }, - [value, cursorPosition, onChange, lineInfo, cursorRow, handleSelectionDeletion], + [ + value, + cursorPosition, + onChange, + lineInfo, + cursorRow, + handleSelectionDeletion, + ], ) // Handle navigation keys (arrows, home, end, word navigation, emacs bindings) @@ -834,35 +847,11 @@ export const MultilineInput = forwardRef< (key: KeyEvent): boolean => { const lowerKeyName = (key.name ?? '').toLowerCase() 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 - ? ((textRef.current as any).textBufferView as TextBufferView)?.lineInfo - : null - - // Calculate visual line boundaries from lineInfo (accounts for word wrap) - // Fall back to logical line boundaries if visual info is unavailable - const lineStarts = currentLineInfo?.lineStartCols ?? [] - const visualLineIndex = lineStarts.findLastIndex( - (start) => start <= cursorPosition, - ) - const visualLineStart = visualLineIndex >= 0 - ? lineStarts[visualLineIndex] - : logicalLineStart - const visualLineEnd = lineStarts[visualLineIndex + 1] !== undefined - ? lineStarts[visualLineIndex + 1] - 1 - : logicalLineEnd // Alt+Left/B: Word left - if ( - isAltLikeModifier && - (key.name === 'left' || lowerKeyName === 'b') - ) { + if (isAltLikeModifier && (key.name === 'left' || lowerKeyName === 'b')) { preventKeyDefault(key) + const wordStart = findPreviousWordBoundary(value, cursorPosition) onChange({ text: value, cursorPosition: wordStart, @@ -872,11 +861,9 @@ export const MultilineInput = forwardRef< } // Alt+Right/F: Word right - if ( - isAltLikeModifier && - (key.name === 'right' || lowerKeyName === 'f') - ) { + if (isAltLikeModifier && (key.name === 'right' || lowerKeyName === 'f')) { preventKeyDefault(key) + const wordEnd = findNextWordBoundary(value, cursorPosition) onChange({ text: value, cursorPosition: wordEnd, @@ -885,6 +872,27 @@ export const MultilineInput = forwardRef< return true } + // Helper to compute visual line boundaries and line starts lazily when needed + const getVisualLineInfo = () => { + const currentLineInfo = textRef.current + ? ((textRef.current as any).textBufferView as TextBufferView) + ?.lineInfo + : null + const lineStarts = currentLineInfo?.lineStartCols ?? [] + const visualLineIndex = lineStarts.findLastIndex( + (start) => start <= cursorPosition, + ) + const visualLineStart = + visualLineIndex >= 0 + ? lineStarts[visualLineIndex] + : findLineStart(value, cursorPosition) + const visualLineEnd = + visualLineIndex >= 0 && lineStarts[visualLineIndex + 1] !== undefined + ? lineStarts[visualLineIndex + 1] - 1 + : findLineEnd(value, cursorPosition) + return { lineStarts, visualLineStart, visualLineEnd } + } + // Cmd+Left, Ctrl+A, or Home: Line start if ( (key.meta && key.name === 'left' && !isAltLikeModifier) || @@ -892,6 +900,7 @@ export const MultilineInput = forwardRef< (key.name === 'home' && !key.ctrl && !key.meta) ) { preventKeyDefault(key) + const { visualLineStart } = getVisualLineInfo() onChange({ text: value, cursorPosition: visualLineStart, @@ -907,6 +916,7 @@ export const MultilineInput = forwardRef< (key.name === 'end' && !key.ctrl && !key.meta) ) { preventKeyDefault(key) + const { visualLineEnd } = getVisualLineInfo() onChange({ text: value, cursorPosition: visualLineEnd, @@ -978,6 +988,7 @@ export const MultilineInput = forwardRef< // Up arrow (no modifiers) if (key.name === 'up' && !key.ctrl && !key.meta && !key.option) { preventKeyDefault(key) + const { lineStarts } = getVisualLineInfo() const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight) onChange({ text: value, @@ -996,6 +1007,7 @@ export const MultilineInput = forwardRef< // Down arrow (no modifiers) if (key.name === 'down' && !key.ctrl && !key.meta && !key.option) { preventKeyDefault(key) + const { lineStarts } = getVisualLineInfo() const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight) onChange({ text: value, @@ -1013,7 +1025,14 @@ export const MultilineInput = forwardRef< return false }, - [value, cursorPosition, onChange, moveCursor, shouldHighlight, getOrSetStickyColumn], + [ + value, + cursorPosition, + onChange, + moveCursor, + shouldHighlight, + getOrSetStickyColumn, + ], ) // Handle character input (regular chars, tab, and IME/multi-byte input) @@ -1053,7 +1072,8 @@ export const MultilineInput = forwardRef< // gives enough time for split paste sequences to arrive. useEffect(() => { const cliRenderer = appContext.renderer as Record | null - const stdinBuffer = cliRenderer?._stdinBuffer as Record | undefined + const stdinBuffer = cliRenderer?._stdinBuffer as + Record | undefined if (stdinBuffer && typeof stdinBuffer.timeoutMs === 'number') { stdinBuffer.timeoutMs = 100 } @@ -1080,7 +1100,9 @@ export const MultilineInput = forwardRef< // Reset dedup flag after microtask so scrollbox handler (which fires // synchronously after global listeners) sees it as handled, but future // paste events are not blocked. - queueMicrotask(() => { pasteHandledRef.current = false }) + queueMicrotask(() => { + pasteHandledRef.current = false + }) } keyHandler.on('paste', handlePaste) @@ -1127,8 +1149,7 @@ export const MultilineInput = forwardRef< const safeMaxHeight = Math.max(1, maxHeight) const effectiveMinHeight = Math.max(1, Math.min(minHeight, safeMaxHeight)) - const totalLines = - lineInfo === null ? 0 : lineInfo.lineStartCols.length + const totalLines = lineInfo === null ? 0 : lineInfo.lineStartCols.length // Add bottom gutter when cursor is on line 2 of exactly 2 lines const gutterEnabled =