@@ -10,11 +10,11 @@ import {
1010 useState,
1111} from 'react'
1212
13+ import { InputCursor } from './input-cursor'
1314import { useOpentuiPaste } from '../hooks/use-opentui-paste'
1415import { useTheme } from '../hooks/use-theme'
15- import { InputCursor } from './input-cursor '
16+ import { logger } from '../utils/logger '
1617import { clamp } from '../utils/math'
17- import { computeInputLayoutMetrics } from '../utils/text-layout'
1818import { calculateNewCursorPosition } from '../utils/word-wrap-utils'
1919
2020import type { InputValue } from '../state/chat-store'
@@ -23,7 +23,6 @@ import type {
2323 LineInfo,
2424 PasteEvent,
2525 ScrollBoxRenderable,
26- TextBufferView,
2726 TextRenderable,
2827} from '@opentui/core'
2928
@@ -132,12 +131,23 @@ export const MultilineInput = forwardRef<
132131 const scrollBoxRef = useRef<ScrollBoxRenderable | null>(null)
133132 const [measuredCols, setMeasuredCols] = useState<number | null>(null)
134133 const [lastActivity, setLastActivity] = useState(Date.now())
134+ const lineInfoRef = useRef<LineInfo>({
135+ lineStarts: [],
136+ lineWidths: [],
137+ maxLineWidth: 0,
138+ })
135139
136140 // Update last activity on value or cursor changes
137141 useEffect(() => {
138142 setLastActivity(Date.now())
139143 }, [value, cursorPosition])
140144
145+ const textRef = useRef<TextRenderable | null>(null)
146+
147+ if (textRef.current) {
148+ lineInfoRef.current = (textRef.current as any).textBufferView.lineInfo
149+ }
150+
141151 const getEffectiveCols = useCallback(() => {
142152 // Prefer measured viewport columns; fallback to a conservative
143153 // estimate: outer width minus border(2) minus padding(2) = 4.
@@ -180,44 +190,43 @@ export const MultilineInput = forwardRef<
180190 ),
181191 )
182192
183- const getCursorRow = useCallback(() => {
184- const cols = getEffectiveCols()
193+ const cursorRow = Math.max(
194+ 0,
195+ lineInfoRef.current.lineStarts.findLastIndex(
196+ (lineStart) => lineStart <= cursorPosition,
197+ ),
198+ )
185199
186- let lines = 0
187- let index = 0
188- let nextNewline = value.indexOf('\n', index)
189- if (nextNewline === -1 || nextNewline > cursorPosition) {
190- nextNewline = cursorPosition
191- }
192- while (index < cursorPosition) {
193- lines += Math.floor((nextNewline - index) / cols) + 1
194- if ((nextNewline - index) % cols === 0 && nextNewline - index > 0) {
195- // special case for the newline being exactly at the end of the line
196- lines -= 1
197- }
198- index = nextNewline + 1
199- nextNewline = value.indexOf('\n', index)
200- if (nextNewline === -1 || nextNewline > cursorPosition) {
201- nextNewline = cursorPosition
202- }
203- }
204- return lines
205- }, [getEffectiveCols, cursorPosition, value])
200+ logger.info(
201+ { cursorRow, lineInfo: lineInfoRef.current, cursorPosition },
202+ `asdf cursorRow${cursorRow}`,
203+ )
206204
207205 // Auto-scroll to cursor when content changes
208206 useEffect(() => {
209207 const scrollBox = scrollBoxRef.current
210208 if (scrollBox && focused) {
211- const cursorRow = getCursorRow()
212209 const scrollPosition = clamp(
213210 scrollBox.verticalScrollBar.scrollPosition,
214211 Math.max(0, cursorRow - scrollBox.viewport.height + 1),
215212 Math.min(scrollBox.scrollHeight - scrollBox.viewport.height, cursorRow),
216213 )
217214
218215 scrollBox.verticalScrollBar.scrollPosition = scrollPosition
216+ logger.info(
217+ {
218+ scrollBox,
219+ focused,
220+ cursorRow,
221+ scrollPosition,
222+ vsbScrollPosition: scrollBox.verticalScrollBar.scrollPosition,
223+ sbHeight: scrollBox.viewport.height,
224+ },
225+ 'asdf 1',
226+ )
219227 }
220- }, [value, cursorPosition, focused, getCursorRow])
228+ logger.info({ scrollBox, focused }, 'asdf 2')
229+ }, [scrollBoxRef.current, cursorPosition, focused, cursorRow])
221230
222231 // Measure actual viewport width from the scrollbox to avoid
223232 // wrap miscalculations from heuristic padding/border math.
@@ -231,22 +240,7 @@ export const MultilineInput = forwardRef<
231240 // viewport.width already reflects inner content area; don't subtract again
232241 const cols = Math.max(1, vpWidth)
233242 setMeasuredCols(cols)
234- }, [scrollBoxRef.current, width])
235-
236- const textRef = useRef<TextRenderable | null>(null)
237-
238- // Helper function to get current lineInfo from the text ref
239- const getLineInfo = useCallback(() => {
240- if (!textRef.current) {
241- return {
242- lineStarts: [],
243- lineWidths: [],
244- maxLineWidth: 0,
245- } satisfies LineInfo
246- }
247-
248- return ((textRef.current as any).textBufferView as TextBufferView).lineInfo
249- }, [])
243+ }, [scrollBoxRef.current, scrollBoxRef.current?.viewport?.width, width])
250244
251245 const insertTextAtCursor = useCallback(
252246 (textToInsert: string) => {
@@ -293,78 +287,43 @@ export const MultilineInput = forwardRef<
293287 renderCursorPosition += displayValue[i] === '\t' ? TAB_WIDTH : 1
294288 }
295289
296- const {
297- beforeCursor,
298- afterCursor,
299- activeChar,
300- shouldHighlight,
301- layoutContent,
302- cursorProbe,
303- } = useMemo(() => {
304- if (!showCursor) {
305- const layoutText = displayValueForRendering
306- const safeCursor = Math.max(
290+ const { beforeCursor, afterCursor, activeChar, shouldHighlight } =
291+ useMemo(() => {
292+ if (!showCursor) {
293+ return {
294+ beforeCursor: '',
295+ afterCursor: '',
296+ activeChar: ' ',
297+ shouldHighlight: false,
298+ }
299+ }
300+
301+ const beforeCursor = displayValueForRendering.slice(
307302 0,
308- Math.min( renderCursorPosition, layoutText.length) ,
303+ renderCursorPosition,
309304 )
305+ const afterCursor = displayValueForRendering.slice(renderCursorPosition)
306+ const activeChar = afterCursor.charAt(0) || ' '
307+ const shouldHighlight =
308+ !isPlaceholder &&
309+ renderCursorPosition < displayValueForRendering.length &&
310+ displayValue[cursorPosition] !== '\n' &&
311+ displayValue[cursorPosition] !== '\t'
310312
311313 return {
312- beforeCursor: '',
313- afterCursor: '',
314- activeChar: ' ',
315- shouldHighlight: false,
316- layoutContent: layoutText,
317- cursorProbe: layoutText.slice(0, safeCursor),
314+ beforeCursor,
315+ afterCursor,
316+ activeChar,
317+ shouldHighlight,
318318 }
319- }
320-
321- const beforeCursor = displayValueForRendering.slice(0, renderCursorPosition)
322- const afterCursor = displayValueForRendering.slice(renderCursorPosition)
323- const activeChar = afterCursor.charAt(0) || ' '
324- const shouldHighlight =
325- !isPlaceholder &&
326- renderCursorPosition < displayValueForRendering.length &&
327- displayValue[cursorPosition] !== '\n' &&
328- displayValue[cursorPosition] !== '\t'
329-
330- // Use the actual input contents for measurement so placeholder text
331- // doesn't change height calculations when the user starts typing.
332- const measurementValue = isPlaceholder
333- ? value.replace(/\t/g, ' '.repeat(TAB_WIDTH))
334- : displayValueForRendering
335-
336- // Calculate measurement cursor position (accounting for tabs in actual value)
337- let measurementCursor = 0
338- const sourceValue = isPlaceholder ? value : displayValue
339- for (let i = 0; i < cursorPosition && i < sourceValue.length; i++) {
340- measurementCursor += sourceValue[i] === '\t' ? TAB_WIDTH : 1
341- }
342-
343- const layoutContent = shouldHighlight
344- ? measurementValue
345- : `${measurementValue.slice(0, measurementCursor)}${CURSOR_CHAR}${measurementValue.slice(measurementCursor)}`
346-
347- const cursorProbe = shouldHighlight
348- ? measurementValue.slice(0, measurementCursor + 1)
349- : `${measurementValue.slice(0, measurementCursor)}${CURSOR_CHAR}`
350-
351- return {
352- beforeCursor,
353- afterCursor,
354- activeChar,
355- shouldHighlight,
356- layoutContent,
357- cursorProbe,
358- }
359- }, [
360- showCursor,
361- displayValueForRendering,
362- renderCursorPosition,
363- cursorPosition,
364- isPlaceholder,
365- value,
366- displayValue,
367- ])
319+ }, [
320+ showCursor,
321+ displayValueForRendering,
322+ renderCursorPosition,
323+ cursorPosition,
324+ isPlaceholder,
325+ displayValue,
326+ ])
368327
369328 // Handle all keyboard input with advanced shortcuts
370329 useKeyboard(
@@ -759,7 +718,7 @@ export const MultilineInput = forwardRef<
759718 text: value,
760719 cursorPosition: calculateNewCursorPosition({
761720 cursorPosition,
762- lineInfo: getLineInfo() ,
721+ lineInfo: lineInfoRef.current ,
763722 cursorIsChar: !shouldHighlight,
764723 direction: 'up',
765724 }),
@@ -774,7 +733,7 @@ export const MultilineInput = forwardRef<
774733 text: value,
775734 cursorPosition: calculateNewCursorPosition({
776735 cursorPosition,
777- lineInfo: getLineInfo() ,
736+ lineInfo: lineInfoRef.current ,
778737 cursorIsChar: !shouldHighlight,
779738 direction: 'down',
780739 }),
@@ -816,7 +775,7 @@ export const MultilineInput = forwardRef<
816775 value,
817776 cursorPosition,
818777 shouldHighlight,
819- getLineInfo ,
778+ lineInfoRef.current ,
820779 onChange,
821780 onSubmit,
822781 onKeyIntercept,
@@ -826,17 +785,29 @@ export const MultilineInput = forwardRef<
826785 ),
827786 )
828787
829- const layoutMetrics = useMemo(
830- () =>
831- computeInputLayoutMetrics({
832- layoutContent,
833- cursorProbe,
834- cols: getEffectiveCols(),
835- maxHeight,
836- minHeight,
837- }),
838- [layoutContent, cursorProbe, getEffectiveCols, maxHeight, minHeight],
839- )
788+ const layoutMetrics = useMemo(() => {
789+ const safeMaxHeight = Math.max(1, maxHeight)
790+ const effectiveMinHeight = Math.max(1, Math.min(minHeight, safeMaxHeight))
791+
792+ const totalLines =
793+ measuredCols === 0 ? 0 : lineInfoRef.current.lineStarts.length
794+
795+ // Add bottom gutter when cursor is on line 2 of exactly 2 lines
796+ const gutterEnabled =
797+ totalLines === 2 && cursorRow === 1 && totalLines + 1 <= safeMaxHeight
798+
799+ const rawHeight = Math.min(
800+ totalLines + (gutterEnabled ? 1 : 0),
801+ safeMaxHeight,
802+ )
803+
804+ const heightLines = Math.max(effectiveMinHeight, rawHeight)
805+
806+ return {
807+ heightLines,
808+ gutterEnabled,
809+ }
810+ }, [maxHeight, minHeight, lineInfoRef.current, cursorRow])
840811
841812 const inputColor = isPlaceholder
842813 ? theme.muted
0 commit comments