1- import { useCallback , useState , useEffect } from 'react'
1+ import { useCallback , useState , useEffect , useMemo , useRef } from 'react'
22import { useKeyboard } from '@opentui/react'
3+ import type { ScrollBoxRenderable } from '@opentui/core'
34
45interface 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