Skip to content

Commit e1505c9

Browse files
committed
refactor(cli): extract multiline input hooks (Commits 2.5a + 2.5b)
Extracts keyboard handling logic from multiline-input.tsx into dedicated hooks: - useKeyboardNavigation: Arrow keys, word/line/document navigation - useKeyboardShortcuts: Enter handling, deletion shortcuts Extracts pure factory functions for testability: - createKeyboardNavigationHandler - createEnterKeysHandler - createDeletionKeysHandler New utility files: - text-navigation.ts: findLineStart, findLineEnd, findPreviousWordBoundary, findNextWordBoundary - keyboard-event-utils.ts: isAltModifier, preventKeyDefault Comprehensive test coverage: - Unit tests for all 4 text-navigation functions (90 tests including Unicode) - Tests import actual production factory functions (not reimplementations) - Fixed bug: cursorDown now clamps to value.length instead of returning Infinity - Performance fix: hooks use useMemo instead of useCallback for handler memoization
1 parent 5e9240e commit e1505c9

13 files changed

+3342
-793
lines changed

cli/src/components/multiline-input.tsx

Lines changed: 104 additions & 793 deletions
Large diffs are not rendered by default.

cli/src/hooks/__tests__/use-keyboard-navigation.test.ts

Lines changed: 654 additions & 0 deletions
Large diffs are not rendered by default.

cli/src/hooks/__tests__/use-keyboard-shortcuts.test.ts

Lines changed: 847 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useCallback, useMemo } from 'react'
2+
3+
import {
4+
findLineEnd,
5+
findLineStart,
6+
findNextWordBoundary,
7+
findPreviousWordBoundary,
8+
} from '../utils/text-navigation'
9+
import { isAltModifier, preventKeyDefault } from '../utils/keyboard-event-utils'
10+
import { calculateNewCursorPosition } from '../utils/word-wrap-utils'
11+
12+
import type { InputValue } from '../state/chat-store'
13+
import type { KeyEvent, TextBufferView } from '@opentui/core'
14+
import type { MutableRefObject } from 'react'
15+
16+
/**
17+
* Dependencies for the keyboard navigation handler.
18+
* Exported for testability - tests can use this type with createKeyboardNavigationHandler.
19+
*/
20+
export type NavigationHandlerDeps = {
21+
stateRef: MutableRefObject<{ value: string; cursorPosition: number }>
22+
onChange: (value: InputValue) => void
23+
moveCursor: (nextPosition: number) => void
24+
shouldHighlight: boolean
25+
lineInfo: TextBufferView['lineInfo'] | null
26+
getOrSetStickyColumn: (lineStarts: number[], cursorIsChar: boolean) => number
27+
}
28+
29+
/**
30+
* @deprecated Use NavigationHandlerDeps instead
31+
*/
32+
export type KeyboardNavigationOptions = NavigationHandlerDeps
33+
34+
/**
35+
* Pure factory function that creates a keyboard navigation handler.
36+
* Extracted for testability - tests can import and use this directly.
37+
*
38+
* Handles:
39+
* - Arrow keys (up/down/left/right)
40+
* - Home/End and Cmd+Left/Right, Ctrl+A/E
41+
* - Word navigation via Alt+Left/Right and Alt+B/F
42+
* - Document navigation via Cmd+Up/Down and Ctrl+Home/End
43+
*/
44+
export function createKeyboardNavigationHandler({
45+
stateRef,
46+
onChange,
47+
moveCursor,
48+
shouldHighlight,
49+
lineInfo,
50+
getOrSetStickyColumn,
51+
}: NavigationHandlerDeps) {
52+
return (key: KeyEvent): boolean => {
53+
// Read fresh state from ref to avoid stale closures
54+
const { value, cursorPosition } = stateRef.current
55+
56+
const lowerKeyName = (key.name ?? '').toLowerCase()
57+
const isAltLikeModifier = isAltModifier(key)
58+
59+
// Lazy getters for boundary calculations - only computed when needed
60+
const getLogicalLineStart = () => findLineStart(value, cursorPosition)
61+
const getLogicalLineEnd = () => findLineEnd(value, cursorPosition)
62+
const getWordStart = () => findPreviousWordBoundary(value, cursorPosition)
63+
const getWordEnd = () => findNextWordBoundary(value, cursorPosition)
64+
65+
const lineStarts = lineInfo?.lineStarts ?? []
66+
const getVisualLineIndex = () =>
67+
lineStarts.findLastIndex((start) => start <= cursorPosition)
68+
const getVisualLineStart = () => {
69+
const idx = getVisualLineIndex()
70+
return idx >= 0 ? lineStarts[idx] : getLogicalLineStart()
71+
}
72+
const getVisualLineEnd = () => {
73+
const idx = getVisualLineIndex()
74+
return lineStarts[idx + 1] !== undefined
75+
? lineStarts[idx + 1] - 1
76+
: getLogicalLineEnd()
77+
}
78+
79+
// Alt+Left/B: Word left
80+
if (isAltLikeModifier && (key.name === 'left' || lowerKeyName === 'b')) {
81+
preventKeyDefault(key)
82+
onChange({
83+
text: value,
84+
cursorPosition: getWordStart(),
85+
lastEditDueToNav: false,
86+
})
87+
return true
88+
}
89+
90+
// Alt+Right/F: Word right
91+
if (isAltLikeModifier && (key.name === 'right' || lowerKeyName === 'f')) {
92+
preventKeyDefault(key)
93+
onChange({
94+
text: value,
95+
cursorPosition: getWordEnd(),
96+
lastEditDueToNav: false,
97+
})
98+
return true
99+
}
100+
101+
// Cmd+Left, Ctrl+A, or Home: Line start
102+
if (
103+
(key.meta && key.name === 'left' && !isAltLikeModifier) ||
104+
(key.ctrl && lowerKeyName === 'a' && !key.meta && !key.option) ||
105+
(key.name === 'home' && !key.ctrl && !key.meta)
106+
) {
107+
preventKeyDefault(key)
108+
onChange({
109+
text: value,
110+
cursorPosition: getVisualLineStart(),
111+
lastEditDueToNav: false,
112+
})
113+
return true
114+
}
115+
116+
// Cmd+Right, Ctrl+E, or End: Line end
117+
if (
118+
(key.meta && key.name === 'right' && !isAltLikeModifier) ||
119+
(key.ctrl && lowerKeyName === 'e' && !key.meta && !key.option) ||
120+
(key.name === 'end' && !key.ctrl && !key.meta)
121+
) {
122+
preventKeyDefault(key)
123+
onChange({
124+
text: value,
125+
cursorPosition: getVisualLineEnd(),
126+
lastEditDueToNav: false,
127+
})
128+
return true
129+
}
130+
131+
// Cmd+Up or Ctrl+Home: Document start
132+
if (
133+
(key.meta && key.name === 'up') ||
134+
(key.ctrl && key.name === 'home')
135+
) {
136+
preventKeyDefault(key)
137+
onChange({ text: value, cursorPosition: 0, lastEditDueToNav: false })
138+
return true
139+
}
140+
141+
// Cmd+Down or Ctrl+End: Document end
142+
if (
143+
(key.meta && key.name === 'down') ||
144+
(key.ctrl && key.name === 'end')
145+
) {
146+
preventKeyDefault(key)
147+
onChange({
148+
text: value,
149+
cursorPosition: value.length,
150+
lastEditDueToNav: false,
151+
})
152+
return true
153+
}
154+
155+
// Ctrl+B: Backward char (Emacs)
156+
if (key.ctrl && lowerKeyName === 'b' && !key.meta && !key.option) {
157+
preventKeyDefault(key)
158+
onChange({
159+
text: value,
160+
cursorPosition: Math.max(0, cursorPosition - 1),
161+
lastEditDueToNav: false,
162+
})
163+
return true
164+
}
165+
166+
// Ctrl+F: Forward char (Emacs)
167+
if (key.ctrl && lowerKeyName === 'f' && !key.meta && !key.option) {
168+
preventKeyDefault(key)
169+
onChange({
170+
text: value,
171+
cursorPosition: Math.min(value.length, cursorPosition + 1),
172+
lastEditDueToNav: false,
173+
})
174+
return true
175+
}
176+
177+
// Left arrow (no modifiers)
178+
if (key.name === 'left' && !key.ctrl && !key.meta && !key.option) {
179+
preventKeyDefault(key)
180+
moveCursor(cursorPosition - 1)
181+
return true
182+
}
183+
184+
// Right arrow (no modifiers)
185+
if (key.name === 'right' && !key.ctrl && !key.meta && !key.option) {
186+
preventKeyDefault(key)
187+
moveCursor(cursorPosition + 1)
188+
return true
189+
}
190+
191+
// Up arrow (no modifiers)
192+
if (key.name === 'up' && !key.ctrl && !key.meta && !key.option) {
193+
preventKeyDefault(key)
194+
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
195+
onChange({
196+
text: value,
197+
cursorPosition: calculateNewCursorPosition({
198+
cursorPosition,
199+
lineStarts,
200+
cursorIsChar: !shouldHighlight,
201+
direction: 'up',
202+
desiredIndex,
203+
}),
204+
lastEditDueToNav: false,
205+
})
206+
return true
207+
}
208+
209+
// Down arrow (no modifiers)
210+
if (key.name === 'down' && !key.ctrl && !key.meta && !key.option) {
211+
preventKeyDefault(key)
212+
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
213+
const newPos = calculateNewCursorPosition({
214+
cursorPosition,
215+
lineStarts,
216+
cursorIsChar: !shouldHighlight,
217+
direction: 'down',
218+
desiredIndex,
219+
})
220+
onChange({
221+
text: value,
222+
// Clamp to value.length - calculateNewCursorPosition returns Infinity for last line
223+
cursorPosition: Math.min(newPos, value.length),
224+
lastEditDueToNav: false,
225+
})
226+
return true
227+
}
228+
229+
return false
230+
}
231+
}
232+
233+
/**
234+
* Hook that returns a handler for navigation-related keys in the multiline input.
235+
* Wraps createKeyboardNavigationHandler in useMemo for React memoization.
236+
*/
237+
export function useKeyboardNavigation(
238+
deps: NavigationHandlerDeps,
239+
): (key: KeyEvent) => boolean {
240+
return useMemo(
241+
() => createKeyboardNavigationHandler(deps),
242+
[
243+
deps.stateRef,
244+
deps.onChange,
245+
deps.moveCursor,
246+
deps.shouldHighlight,
247+
deps.lineInfo,
248+
deps.getOrSetStickyColumn,
249+
],
250+
)
251+
}

0 commit comments

Comments
 (0)