fix(tui): word-wrap composer input (#17651)

* fix(tui): word-wrap composer input

Wrap composer input at word boundaries and anchor the good-vibes heart to the full composer row.

* test(tui): cover composer word wrap edge

Add regression coverage for moving the next word instead of splitting it at the composer edge.
This commit is contained in:
brooklyn! 2026-04-29 16:55:49 -07:00 committed by GitHub
parent 5e6e8b6af3
commit 98f5be13fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 170 additions and 96 deletions

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { offsetFromPosition } from '../components/textInput.js' import { offsetFromPosition } from '../components/textInput.js'
import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
describe('cursorLayout — char-wrap parity with wrap-ansi', () => { describe('cursorLayout — word-wrap parity with wrap-ansi', () => {
it('places cursor mid-line at its column', () => { it('places cursor mid-line at its column', () => {
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 }) expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
}) })
@ -18,12 +18,20 @@ describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 }) expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 })
}) })
it('tracks a word across a char-wrap boundary without jumping', () => { it('moves words across wrap boundaries instead of splitting them', () => {
// With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" — // With wordWrap:true, "hello wor" at cols=8 is "hello \nwor" rather
// typing incremental letters doesn't reshuffle the word across lines. // than "hello wo\nr".
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 }) expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 })
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 }) expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 3, line: 1 })
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 }) expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 4, line: 1 })
expect(cursorLayout('hello world', 11, 8)).toEqual({ column: 5, line: 1 })
})
it('wraps the next word instead of splitting it at the right edge', () => {
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
expect(cursorLayout(text, text.length, 70)).toEqual({ column: 4, line: 1 })
expect(inputVisualHeight(text, 70)).toBe(2)
}) })
it('honours explicit newlines', () => { it('honours explicit newlines', () => {
@ -56,7 +64,7 @@ describe('input metrics helpers', () => {
}) })
}) })
describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => { describe('offsetFromPosition — word-wrap inverse of cursorLayout', () => {
it('returns 0 for empty input', () => { it('returns 0 for empty input', () => {
expect(offsetFromPosition('', 0, 0, 10)).toBe(0) expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
}) })
@ -70,11 +78,23 @@ describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
}) })
it('maps clicks on a wrapped second row at cols boundary', () => { it('maps clicks on a wrapped second row at cols boundary', () => {
// "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0 // Long words still hard-wrap when there is no word boundary.
// should land on 'i' (offset 8).
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8) expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
}) })
it('maps clicks on a word-wrapped second row', () => {
// "hello world" at cols=8 wraps to "hello \nworld".
expect(offsetFromPosition('hello world', 1, 0, 8)).toBe(6)
expect(offsetFromPosition('hello world', 1, 3, 8)).toBe(9)
})
it('maps clicks on the moved final word', () => {
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
expect(offsetFromPosition(text, 1, 0, 70)).toBe(text.indexOf('wish'))
expect(offsetFromPosition(text, 1, 3, 70)).toBe(text.indexOf('wish') + 3)
})
it('maps clicks past a \\n into the target line', () => { it('maps clicks past a \\n into the target line', () => {
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6) expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
}) })

View file

@ -263,6 +263,7 @@ const ComposerPane = memo(function ComposerPane({
onMouseDrag={dragFromPromptRow} onMouseDrag={dragFromPromptRow}
onMouseUp={endInputDrag} onMouseUp={endInputDrag}
position="relative" position="relative"
width={Math.max(1, composer.cols - 2)}
> >
<Box width={promptWidth}> <Box width={promptWidth}>
{sh ? ( {sh ? (
@ -274,7 +275,7 @@ const ComposerPane = memo(function ComposerPane({
)} )}
</Box> </Box>
<Box flexGrow={0} flexShrink={0} height={inputHeight} position="relative" width={inputColumns}> <Box flexGrow={0} flexShrink={0} height={inputHeight} width={inputColumns}>
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
<TextInput <TextInput
columns={inputColumns} columns={inputColumns}
@ -285,12 +286,12 @@ const ComposerPane = memo(function ComposerPane({
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
value={composer.input} value={composer.input}
/> />
</Box>
<Box position="absolute" right={0}> <Box position="absolute" right={0}>
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} /> <GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
</Box> </Box>
</Box> </Box>
</Box>
</> </>
)} )}
</Box> </Box>

View file

@ -4,7 +4,7 @@ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'rea
import { setInputSelection } from '../app/inputSelectionStore.js' import { setInputSelection } from '../app/inputSelectionStore.js'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js' import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { cursorLayout } from '../lib/inputMetrics.js' import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js'
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js' import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
type InkExt = typeof Ink & { type InkExt = typeof Ink & {
@ -170,57 +170,7 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd)) return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
} }
export function offsetFromPosition(value: string, row: number, col: number, cols: number) { export { offsetFromPosition }
if (!value.length) {
return 0
}
const targetRow = Math.max(0, Math.floor(row))
const targetCol = Math.max(0, Math.floor(col))
const w = Math.max(1, cols)
let line = 0
let column = 0
let lastOffset = 0
for (const { segment, index } of seg().segment(value)) {
lastOffset = index
if (segment === '\n') {
if (line === targetRow) {
return index
}
line++
column = 0
continue
}
const sw = Math.max(1, stringWidth(segment))
if (column + sw > w) {
if (line === targetRow) {
return index
}
line++
column = 0
}
if (line === targetRow && targetCol <= column + Math.max(0, sw - 1)) {
return index
}
column += sw
}
if (targetRow >= line) {
return value.length
}
return lastOffset
}
function renderWithCursor(value: string, cursor: number) { function renderWithCursor(value: string, cursor: number) {
const pos = Math.max(0, Math.min(cursor, value.length)) const pos = Math.max(0, Math.min(cursor, value.length))
@ -1059,7 +1009,7 @@ export function TextInput({
ref={boxRef} ref={boxRef}
width={columns} width={columns}
> >
<Text wrap="wrap-char">{rendered}</Text> <Text wrap="wrap">{rendered}</Text>
</Box> </Box>
) )
} }

View file

@ -5,50 +5,153 @@ export const COMPOSER_PROMPT_GAP_WIDTH = 1
let _seg: Intl.Segmenter | null = null let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
interface VisualLine {
end: number
start: number
}
const isWhitespace = (value: string) => /\s/.test(value)
const graphemes = (value: string) =>
[...seg().segment(value)].map(({ segment, index }) => ({
end: index + segment.length,
index,
segment,
width: Math.max(1, stringWidth(segment))
}))
function visualLines(value: string, cols: number): VisualLine[] {
const width = Math.max(1, cols)
const lines: VisualLine[] = []
let sourceLineStart = 0
for (const sourceLine of value.split('\n')) {
const parts = graphemes(sourceLine)
if (!parts.length) {
lines.push({ start: sourceLineStart, end: sourceLineStart })
sourceLineStart += 1
continue
}
let lineStartPart = 0
let lineStartOffset = sourceLineStart
let column = 0
let breakPart: null | number = null
let i = 0
while (i < parts.length) {
const part = parts[i]!
const partStart = sourceLineStart + part.index
if (column + part.width > width && i > lineStartPart) {
if (breakPart !== null && breakPart > lineStartPart) {
const breakOffset = sourceLineStart + parts[breakPart - 1]!.end
lines.push({ start: lineStartOffset, end: breakOffset })
lineStartPart = breakPart
lineStartOffset = breakOffset
} else {
lines.push({ start: lineStartOffset, end: partStart })
lineStartPart = i
lineStartOffset = partStart
}
column = 0
breakPart = null
i = lineStartPart
continue
}
column += part.width
if (isWhitespace(part.segment)) {
breakPart = i + 1
}
i += 1
if (column >= width && i < parts.length) {
const next = parts[i]!
const nextStartsWord = !isWhitespace(next.segment)
if (breakPart !== null && breakPart > lineStartPart && nextStartsWord) {
const breakOffset = sourceLineStart + parts[breakPart - 1]!.end
lines.push({ start: lineStartOffset, end: breakOffset })
lineStartPart = breakPart
lineStartOffset = breakOffset
column = 0
breakPart = null
i = lineStartPart
}
}
}
lines.push({ start: lineStartOffset, end: sourceLineStart + sourceLine.length })
sourceLineStart += sourceLine.length + 1
}
return lines.length ? lines : [{ start: 0, end: 0 }]
}
function widthBetween(value: string, start: number, end: number) {
let width = 0
for (const part of graphemes(value.slice(start, end))) {
width += part.width
}
return width
}
/** /**
* Mirrors the char-wrap behavior used by the composer TextInput. * Mirrors the word-wrap behavior used by the composer TextInput.
* Returns the zero-based visual line and column of the cursor cell. * Returns the zero-based visual line and column of the cursor cell.
*/ */
export function cursorLayout(value: string, cursor: number, cols: number) { export function cursorLayout(value: string, cursor: number, cols: number) {
const pos = Math.max(0, Math.min(cursor, value.length)) const pos = Math.max(0, Math.min(cursor, value.length))
const w = Math.max(1, cols) const w = Math.max(1, cols)
const lines = visualLines(value, w)
let lineIndex = 0
let col = 0, for (let i = 0; i < lines.length; i += 1) {
line = 0 if (lines[i]!.start <= pos) {
lineIndex = i
for (const { segment, index } of seg().segment(value)) { } else {
if (index >= pos) {
break break
} }
if (segment === '\n') {
line++
col = 0
continue
} }
const sw = stringWidth(segment) const line = lines[lineIndex]!
let column = widthBetween(value, line.start, Math.min(pos, line.end))
if (!sw) {
continue
}
if (col + sw > w) {
line++
col = 0
}
col += sw
}
// trailing cursor-cell overflows to the next row at the wrap column // trailing cursor-cell overflows to the next row at the wrap column
if (col >= w) { if (column >= w) {
line++ lineIndex++
col = 0 column = 0
} }
return { column: col, line } return { column, line: lineIndex }
}
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
if (!value.length) {
return 0
}
const lines = visualLines(value, cols)
const target = lines[Math.max(0, Math.min(lines.length - 1, Math.floor(row)))]!
const targetCol = Math.max(0, Math.floor(col))
let column = 0
for (const part of graphemes(value.slice(target.start, target.end))) {
if (targetCol <= column + Math.max(0, part.width - 1)) {
return target.start + part.index
}
column += part.width
}
return target.end
} }
export function inputVisualHeight(value: string, columns: number) { export function inputVisualHeight(value: string, columns: number) {