Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 164 additions & 1 deletion cli/src/components/__tests__/multiline-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { describe, test, expect } from 'bun:test'
import { beforeAll, describe, expect, test } from 'bun:test'
import { createTestRenderer } from '@opentui/core/testing'
import { createRoot, flushSync } from '@opentui/react'
import React from 'react'

import {
getKeypadPrintableSequence,
isKeypadEnter,
} from '../../utils/keypad-keys'
import { initializeThemeStore } from '../../hooks/use-theme'
import {
calculateMultilineInputCursorPosition,
MultilineInput,
} from '../multiline-input'

beforeAll(() => {
initializeThemeStore()
})

/**
* Tests for tab character cursor rendering in MultilineInput component.
Expand Down Expand Up @@ -242,6 +254,157 @@ describe('MultilineInput - tab character handling', () => {
})
})

describe('MultilineInput - hardware cursor coordinates', () => {
const viewportX = 10
const viewportY = 20
const lineInfo = (lineStartCols: number[]) => ({ lineStartCols })

test('places the ASCII caret using terminal cell columns', () => {
expect(
calculateMultilineInputCursorPosition({
text: 'hello',
cursorPosition: 2,
lineInfo: lineInfo([0]),
viewportX,
viewportY,
verticalScrollPosition: 0,
}),
).toEqual({ x: 13, y: 21 })
})

test('advances two terminal cells after a CJK wide character', () => {
expect(
calculateMultilineInputCursorPosition({
text: '你a',
cursorPosition: 1,
lineInfo: lineInfo([0]),
viewportX,
viewportY,
verticalScrollPosition: 0,
}),
).toEqual({ x: 13, y: 21 })
})

test('handles mixed ASCII and CJK widths', () => {
expect(
calculateMultilineInputCursorPosition({
text: 'a你b',
cursorPosition: 2,
lineInfo: lineInfo([0]),
viewportX,
viewportY,
verticalScrollPosition: 0,
}),
).toEqual({ x: 14, y: 21 })
})

test('uses the existing four-cell tab expansion', () => {
expect(
calculateMultilineInputCursorPosition({
text: '\ta',
cursorPosition: 1,
lineInfo: lineInfo([0]),
viewportX,
viewportY,
verticalScrollPosition: 0,
}),
).toEqual({ x: 15, y: 21 })
})

test('uses cumulative visual-line offsets for wrapped text', () => {
expect(
calculateMultilineInputCursorPosition({
text: 'abcdefghij',
cursorPosition: 7,
lineInfo: lineInfo([0, 5]),
viewportX,
viewportY,
verticalScrollPosition: 0,
}),
).toEqual({ x: 13, y: 22 })
})

test('applies the viewport offset and vertical scroll position', () => {
expect(
calculateMultilineInputCursorPosition({
text: 'a\nb\nc',
cursorPosition: 4,
lineInfo: lineInfo([0, 2, 4]),
viewportX,
viewportY,
verticalScrollPosition: 1,
}),
).toEqual({ x: 11, y: 22 })
})
})

describe('MultilineInput - hardware cursor lifecycle', () => {
test('moves the renderer cursor two cells after a CJK character', async () => {
const setup = await createTestRenderer({ width: 30, height: 8 })
const root = createRoot(setup.renderer)

try {
flushSync(() =>
root.render(
<MultilineInput
value="你a"
onChange={() => {}}
onSubmit={() => {}}
onPaste={() => {}}
cursorPosition={1}
maxHeight={3}
shouldBlinkCursor={false}
focused
/>,
),
)
await setup.renderOnce()

expect(setup.renderer.getCursorState()).toMatchObject({
x: 4,
y: 1,
visible: true,
})
} finally {
flushSync(() => root.unmount())
setup.renderer.destroy()
}
})

test('hides the hardware cursor when unfocused and on unmount', async () => {
const setup = await createTestRenderer({ width: 30, height: 8 })
const root = createRoot(setup.renderer)
const props = {
value: 'input',
onChange: () => {},
onSubmit: () => {},
onPaste: () => {},
cursorPosition: 2,
maxHeight: 3,
shouldBlinkCursor: false,
}

try {
flushSync(() => root.render(<MultilineInput {...props} focused />))
await setup.renderOnce()
expect(setup.renderer.getCursorState().visible).toBe(true)

flushSync(() => root.render(<MultilineInput {...props} focused={false} />))
await setup.renderOnce()
expect(setup.renderer.getCursorState().visible).toBe(false)

flushSync(() => root.render(<MultilineInput {...props} focused />))
await setup.renderOnce()
expect(setup.renderer.getCursorState().visible).toBe(true)

flushSync(() => root.unmount())
expect(setup.renderer.getCursorState().visible).toBe(false)
} finally {
setup.renderer.destroy()
}
})
})

/**
* Tests for Chinese/IME character input handling in MultilineInput component.
*
Expand Down
97 changes: 97 additions & 0 deletions cli/src/components/multiline-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
useRef,
useState,
} from 'react'
import stringWidth from 'string-width'

import { InputCursor } from './input-cursor'
import { useTheme } from '../hooks/use-theme'
Expand All @@ -31,6 +32,7 @@ import { calculateNewCursorPosition } from '../utils/word-wrap-utils'
import type { InputValue } from '../types/store'
import type {
KeyEvent,
LineInfo,
MouseEvent,
PasteEvent,
ScrollBoxRenderable,
Expand Down Expand Up @@ -95,6 +97,45 @@ export const CURSOR_CHAR = '▍'
const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b-\u000c\u000e-\u001f\u007f]/
const TAB_WIDTH = 4

export function calculateMultilineInputCursorPosition({
text,
cursorPosition,
lineInfo,
viewportX,
viewportY,
verticalScrollPosition,
}: {
text: string
cursorPosition: number
lineInfo: Pick<LineInfo, 'lineStartCols'> | null
viewportX: number
viewportY: number
verticalScrollPosition: number
}): { x: number; y: number } {
const safeCursorPosition = Math.max(0, Math.min(cursorPosition, text.length))
const renderedPrefix = text
.slice(0, safeCursorPosition)
.replace(/\t/g, ' '.repeat(TAB_WIDTH))
const displayColumn =
stringWidth(renderedPrefix) + (renderedPrefix.match(/\n/g)?.length ?? 0)

// OpenTUI reports lineStartCols as cumulative display-column offsets for
// visual lines. Compare against the rendered width, not the source index.
const lineStarts = lineInfo?.lineStartCols ?? [0]
const visualRow = Math.max(
0,
lineStarts.findLastIndex((lineStart) => lineStart <= displayColumn),
)
const visualLineStart = lineStarts[visualRow] ?? 0

// OpenTUI's renderer cursor coordinates are 1-based terminal coordinates;
// viewport x/y are 0-based screen layout coordinates.
return {
x: viewportX + displayColumn - visualLineStart + 1,
y: viewportY + visualRow - verticalScrollPosition + 1,
}
}

/**
* Check if a key event represents printable character input (not a special key).
* Uses a positive heuristic based on key.name length rather than a brittle deny-list.
Expand Down Expand Up @@ -236,10 +277,12 @@ export const MultilineInput = forwardRef<
// updated synchronously to ensure each keystroke builds on the previous one.
const valueRef = useRef(value)
const cursorPositionRef = useRef(cursorPosition)
const focusedRef = useRef(focused)

// Keep refs current on every render (synchronous assignment avoids useEffect timing issues)
valueRef.current = value
cursorPositionRef.current = cursorPosition
focusedRef.current = focused

// Helper to get or set the sticky column for vertical navigation.
// When stickyColumnRef.current is set, we return it (preserving column across
Expand Down Expand Up @@ -513,6 +556,60 @@ export const MultilineInput = forwardRef<
/\t/g,
' '.repeat(TAB_WIDTH),
)
const hardwareCursorTextRef = useRef(displayValue)
hardwareCursorTextRef.current = displayValue

// Keep the terminal's hardware cursor at the same screen-cell position as
// the existing visual caret so terminal IMEs can anchor their UI correctly.
const syncHardwareCursor = useCallback(() => {
if (!focusedRef.current) {
renderer.setCursorPosition(0, 0, false)
return
}

const scrollBox = scrollBoxRef.current
const textBufferView = textRef.current
? ((textRef.current as any).textBufferView as TextBufferView)
: null

if (!scrollBox || !textBufferView) {
renderer.setCursorPosition(0, 0, false)
return
}

const viewport = scrollBox.viewport
const cursor = calculateMultilineInputCursorPosition({
text: hardwareCursorTextRef.current,
cursorPosition: cursorPositionRef.current,
lineInfo: textBufferView.lineInfo,
viewportX: Number(viewport.x),
viewportY: Number(viewport.y),
verticalScrollPosition: scrollBox.verticalScrollBar.scrollPosition,
})

renderer.setCursorPosition(cursor.x, cursor.y, true)
}, [renderer])

useEffect(() => {
syncHardwareCursor()

if (!focused) return

// React effects can run before OpenTUI has completed the layout pass that
// establishes wrapping and viewport coordinates. TextRenderable emits
// this event after its line information is recalculated.
const textRenderable = textRef.current
textRenderable?.on('line-info-change', syncHardwareCursor)
return () => {
textRenderable?.off('line-info-change', syncHardwareCursor)
}
}, [focused, displayValue, cursorPosition, lineInfo, syncHardwareCursor])

useEffect(() => {
return () => {
renderer.setCursorPosition(0, 0, false)
}
}, [renderer])

// Calculate cursor position in the expanded string (accounting for tabs)
let renderCursorPosition = 0
Expand Down
Loading