Skip to content

Commit ada858a

Browse files
committed
refactor(cli): extract multiline input hooks (Commits 2.5a + 2.5b)
- Create useKeyboardNavigation hook for arrow/word/line navigation - Create useKeyboardShortcuts hook for enter/deletion handling - Create useTextEditing hook for character input and cursor movement - Create useTextSelection hook for selection management - Create useMouseInput hook for click-to-cursor positioning - Add text-navigation.ts and keyboard-event-utils.ts utilities - Add opentui-internals.ts type definitions - Add TAB_WIDTH constant - Reduce multiline-input.tsx from ~1100 to ~320 lines (-71%)
1 parent 5f36841 commit ada858a

File tree

10 files changed

+1179
-793
lines changed

10 files changed

+1179
-793
lines changed

cli/src/components/multiline-input.tsx

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

0 commit comments

Comments
 (0)