Skip to content

Commit 1581297

Browse files
committed
word wrap for input
1 parent dad7e5c commit 1581297

File tree

2 files changed

+122
-61
lines changed

2 files changed

+122
-61
lines changed

cli/src/components/multiline-input.tsx

Lines changed: 61 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,17 @@ import { useOpentuiPaste } from '../hooks/use-opentui-paste'
1414
import { useTheme } from '../hooks/use-theme'
1515
import { clamp } from '../utils/math'
1616
import { computeInputLayoutMetrics } from '../utils/text-layout'
17+
import { calculateNewCursorPosition } from '../utils/word-wrap-utils'
1718

1819
import type { InputValue } from '../state/chat-store'
19-
import type { KeyEvent, PasteEvent, ScrollBoxRenderable } from '@opentui/core'
20+
import type {
21+
KeyEvent,
22+
LineInfo,
23+
PasteEvent,
24+
ScrollBoxRenderable,
25+
TextBufferView,
26+
TextRenderable,
27+
} from '@opentui/core'
2028

2129
// Helper functions for text manipulation
2230
function findLineStart(text: string, cursor: number): number {
@@ -210,6 +218,33 @@ export const MultilineInput = forwardRef<
210218
setMeasuredCols(cols)
211219
}, [width])
212220

221+
const textRef = useRef<TextRenderable | null>(null)
222+
223+
// Helper function to get current lineInfo from the text ref
224+
const getLineInfo = useCallback(() => {
225+
if (!textRef.current) {
226+
return {
227+
lineStarts: [],
228+
lineWidths: [],
229+
maxLineWidth: 0,
230+
} satisfies LineInfo
231+
}
232+
233+
return ((textRef.current as any).textBufferView as TextBufferView).lineInfo
234+
}, [])
235+
236+
const isPlaceholder = value.length === 0 && placeholder.length > 0
237+
const displayValue = isPlaceholder ? placeholder : value
238+
const showCursor = focused
239+
const beforeCursor = showCursor ? displayValue.slice(0, cursorPosition) : ''
240+
const afterCursor = showCursor ? displayValue.slice(cursorPosition) : ''
241+
const activeChar = afterCursor.charAt(0) || ' '
242+
const shouldHighlight =
243+
showCursor &&
244+
!isPlaceholder &&
245+
cursorPosition < displayValue.length &&
246+
displayValue[cursorPosition] !== '\n'
247+
213248
// Handle all keyboard input with advanced shortcuts
214249
useKeyboard(
215250
useCallback(
@@ -607,65 +642,32 @@ export const MultilineInput = forwardRef<
607642
// Up arrow (no modifiers)
608643
if (key.name === 'up' && !key.ctrl && !key.meta && !key.option) {
609644
preventKeyDefault(key)
610-
const cols = getEffectiveCols()
611-
const prevNewline = value.lastIndexOf('\n', cursorPosition - 1)
612-
if (cursorPosition - cols >= prevNewline) {
613-
onChange({
614-
text: value,
615-
cursorPosition: cursorPosition - cols,
616-
lastEditDueToNav: false,
617-
})
618-
return
619-
}
620-
621-
const priorNewline = value.lastIndexOf('\n', prevNewline - 1)
622-
const lastParagraphLength = prevNewline - priorNewline
623-
const lastRow = Math.floor(lastParagraphLength / cols)
624645
onChange({
625646
text: value,
626-
cursorPosition: Math.min(
627-
priorNewline + lastRow * cols + cursorPosition - prevNewline,
628-
prevNewline,
629-
),
647+
cursorPosition: calculateNewCursorPosition({
648+
cursorPosition,
649+
lineInfo: getLineInfo(),
650+
cursorIsChar: !shouldHighlight,
651+
direction: 'up',
652+
}),
630653
lastEditDueToNav: false,
631654
})
655+
return
632656
}
633657

634658
// Down arrow (no modifiers)
635659
if (key.name === 'down' && !key.ctrl && !key.meta && !key.option) {
636-
preventKeyDefault(key)
637-
const cols = getEffectiveCols()
638-
let nextNewlineInclusive = value.indexOf('\n', cursorPosition)
639-
if (nextNewlineInclusive === -1) {
640-
nextNewlineInclusive = value.length
641-
}
642-
if (cursorPosition + cols <= nextNewlineInclusive) {
643-
onChange({
644-
text: value,
645-
cursorPosition: cursorPosition + cols,
646-
lastEditDueToNav: false,
647-
})
648-
return
649-
}
650-
651-
let afterNewline = value.indexOf('\n', nextNewlineInclusive + 1)
652-
if (afterNewline === -1) {
653-
afterNewline = value.length
654-
}
655-
/*
656-
* The second argument of lastIndexOf is converted to 0 if it's
657-
* negative, so we need to special case cursorPosition === 0
658-
*/
659-
let prevNewlineExclusive =
660-
cursorPosition === 0
661-
? -1
662-
: value.lastIndexOf('\n', cursorPosition - 1)
663-
const col = (cursorPosition - prevNewlineExclusive) % cols
664660
onChange({
665661
text: value,
666-
cursorPosition: Math.min(nextNewlineInclusive + col, afterNewline),
662+
cursorPosition: calculateNewCursorPosition({
663+
cursorPosition,
664+
lineInfo: getLineInfo(),
665+
cursorIsChar: !shouldHighlight,
666+
direction: 'down',
667+
}),
667668
lastEditDueToNav: false,
668669
})
670+
return
669671
}
670672

671673
// Regular character input
@@ -689,22 +691,20 @@ export const MultilineInput = forwardRef<
689691
return
690692
}
691693
},
692-
[focused, value, cursorPosition, onChange, onSubmit, onKeyIntercept],
694+
[
695+
focused,
696+
value,
697+
cursorPosition,
698+
shouldHighlight,
699+
getLineInfo,
700+
onChange,
701+
onSubmit,
702+
onKeyIntercept,
703+
],
693704
),
694705
)
695706

696707
// Calculate display with cursor
697-
const isPlaceholder = value.length === 0 && placeholder.length > 0
698-
const displayValue = isPlaceholder ? placeholder : value
699-
const showCursor = focused
700-
const beforeCursor = showCursor ? displayValue.slice(0, cursorPosition) : ''
701-
const afterCursor = showCursor ? displayValue.slice(cursorPosition) : ''
702-
const activeChar = afterCursor.charAt(0) || ' '
703-
const shouldHighlight =
704-
showCursor &&
705-
!isPlaceholder &&
706-
cursorPosition < displayValue.length &&
707-
displayValue[cursorPosition] !== '\n'
708708

709709
const layoutContent = showCursor
710710
? shouldHighlight
@@ -777,7 +777,7 @@ export const MultilineInput = forwardRef<
777777
},
778778
}}
779779
>
780-
<text style={{ ...textStyle, wrapMode: 'char' }}>
780+
<text ref={textRef} style={{ ...textStyle, wrapMode: 'word' }}>
781781
{showCursor ? (
782782
<>
783783
{beforeCursor}

cli/src/utils/word-wrap-utils.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { LineInfo } from '@opentui/core'
2+
3+
function cursorUp(params: {
4+
lineInfo: LineInfo
5+
cursorPosition: number
6+
}): number {
7+
const {
8+
lineInfo: { lineStarts },
9+
cursorPosition,
10+
} = params
11+
const lineIndex = lineStarts.findLastIndex((start) => start <= cursorPosition)
12+
13+
if (lineIndex === -1 || lineIndex === 0) {
14+
return 0
15+
}
16+
17+
const index = cursorPosition - lineStarts[lineIndex]
18+
return Math.min(lineStarts[lineIndex] - 1, lineStarts[lineIndex - 1] + index)
19+
}
20+
21+
function cursorDown(params: {
22+
lineInfo: LineInfo
23+
cursorPosition: number
24+
cursorIsChar: boolean
25+
}): number {
26+
const {
27+
lineInfo: { lineStarts },
28+
cursorPosition,
29+
cursorIsChar,
30+
} = params
31+
const lineIndex = lineStarts.findLastIndex((start) => start <= cursorPosition)
32+
33+
if (lineIndex === -1 || lineIndex === lineStarts.length - 1) {
34+
return Infinity
35+
}
36+
37+
// Need to account for cursor character itself
38+
const index = cursorPosition - lineStarts[lineIndex] + (cursorIsChar ? -1 : 0)
39+
40+
return Math.min(
41+
(lineStarts[lineIndex + 2] ?? Infinity) - 1,
42+
lineStarts[lineIndex + 1] + index,
43+
)
44+
}
45+
46+
export function calculateNewCursorPosition(params: {
47+
cursorPosition: number
48+
lineInfo: LineInfo
49+
cursorIsChar: boolean
50+
direction: 'up' | 'down'
51+
}): number {
52+
const { direction } = params
53+
if (direction === 'up') {
54+
return cursorUp(params)
55+
}
56+
if (direction === 'down') {
57+
return cursorDown(params)
58+
}
59+
direction satisfies never
60+
throw new Error(`Invalid direction: ${direction}`)
61+
}

0 commit comments

Comments
 (0)