Skip to content

Commit ebd2404

Browse files
committed
feat(cli): added robust multiline input
🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 1bfb75a commit ebd2404

File tree

1 file changed

+58
-22
lines changed

1 file changed

+58
-22
lines changed

cli/src/multiline-input.tsx

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useCallback, useState, useEffect } from 'react'
1+
import { useCallback, useState, useEffect, useMemo, useRef } from 'react'
22
import { useKeyboard } from '@opentui/react'
3+
import type { ScrollBoxRenderable } from '@opentui/core'
34

45
interface MultilineInputProps {
56
value: string
@@ -29,6 +30,7 @@ export function MultilineInput({
2930
theme,
3031
width,
3132
}: MultilineInputProps) {
33+
const scrollBoxRef = useRef<ScrollBoxRenderable | null>(null)
3234
const [cursorPosition, setCursorPosition] = useState(value.length)
3335

3436
// Sync cursor when value changes externally
@@ -38,6 +40,21 @@ export function MultilineInput({
3840
}
3941
}, [value.length, cursorPosition])
4042

43+
// Auto-scroll to bottom when content changes
44+
useEffect(() => {
45+
const scrollBox = scrollBoxRef.current
46+
if (scrollBox && focused) {
47+
// Scroll to bottom after layout updates
48+
setTimeout(() => {
49+
const maxScroll = Math.max(
50+
0,
51+
scrollBox.scrollHeight - scrollBox.viewport.height,
52+
)
53+
scrollBox.verticalScrollBar.scrollPosition = maxScroll
54+
}, 0)
55+
}
56+
}, [value, cursorPosition, focused])
57+
4158
// Handle all keyboard input
4259
useKeyboard(
4360
useCallback(
@@ -150,30 +167,49 @@ export function MultilineInput({
150167
displayValue.slice(cursorPosition)
151168
: displayValue
152169

153-
// Calculate height based on wrapped lines
154-
const maxCharsPerLine = Math.max(1, width - 4)
155-
const lines = displayValue.split('\n')
156-
let totalLineCount = 0
157-
for (const line of lines) {
158-
if (line.length === 0) {
159-
totalLineCount += 1
160-
} else {
161-
// Account for cursor character which adds 1 to display length
162-
const displayLength =
163-
focused && !isPlaceholder ? line.length + 1 : line.length
164-
totalLineCount += Math.ceil(displayLength / maxCharsPerLine)
170+
// Memoize height calculation to avoid expensive computation on every render
171+
const height = useMemo(() => {
172+
const maxCharsPerLine = Math.max(1, width - 4)
173+
const lines = displayValue.split('\n')
174+
let totalLineCount = 0
175+
for (const line of lines) {
176+
if (line.length === 0) {
177+
totalLineCount += 1
178+
} else {
179+
// Account for cursor character which adds 1 to display length
180+
const displayLength =
181+
focused && !isPlaceholder ? line.length + 1 : line.length
182+
totalLineCount += Math.ceil(displayLength / maxCharsPerLine)
183+
}
165184
}
166-
}
167-
const height = Math.max(1, Math.min(totalLineCount, maxHeight))
185+
return Math.max(1, Math.min(totalLineCount, maxHeight))
186+
}, [displayValue, width, focused, isPlaceholder, maxHeight])
168187

169188
return (
170-
<box
189+
<scrollbox
190+
ref={scrollBoxRef}
191+
scrollX={false}
192+
stickyScroll={true}
193+
stickyStart="bottom"
194+
scrollbarOptions={{ visible: false }}
171195
style={{
172-
width: '100%',
173-
height: height,
174-
backgroundColor: focused ? theme.inputFocusedBg : theme.inputBg,
175-
paddingLeft: 1,
176-
paddingRight: 1,
196+
flexGrow: 0,
197+
flexShrink: 0,
198+
rootOptions: {
199+
width: '100%',
200+
height: height,
201+
backgroundColor: focused ? theme.inputFocusedBg : theme.inputBg,
202+
flexGrow: 0,
203+
flexShrink: 0,
204+
},
205+
wrapperOptions: {
206+
paddingLeft: 1,
207+
paddingRight: 1,
208+
border: false,
209+
},
210+
contentOptions: {
211+
justifyContent: 'flex-end',
212+
},
177213
}}
178214
>
179215
<text
@@ -188,6 +224,6 @@ export function MultilineInput({
188224
>
189225
{displayText}
190226
</text>
191-
</box>
227+
</scrollbox>
192228
)
193229
}

0 commit comments

Comments
 (0)