diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts index e3ab5d0b32..c25c9629e7 100644 --- a/ui-tui/src/__tests__/textInputWrap.test.ts +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { offsetFromPosition } from '../components/textInput.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', () => { 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 }) }) - it('tracks a word across a char-wrap boundary without jumping', () => { - // With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" — - // typing incremental letters doesn't reshuffle the word across lines. + it('moves words across wrap boundaries instead of splitting them', () => { + // With wordWrap:true, "hello wor" at cols=8 is "hello \nwor" rather + // than "hello wo\nr". 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 worl', 10, 8)).toEqual({ column: 2, line: 1 }) + expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 3, 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', () => { @@ -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', () => { 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', () => { - // "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0 - // should land on 'i' (offset 8). + // Long words still hard-wrap when there is no word boundary. 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', () => { expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6) }) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 6f2d33df97..6927460770 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -263,6 +263,7 @@ const ComposerPane = memo(function ComposerPane({ onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative" + width={Math.max(1, composer.cols - 2)} > {sh ? ( @@ -274,7 +275,7 @@ const ComposerPane = memo(function ComposerPane({ )} - + {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} + - - - + + diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 0052e69ed7..3008f0baf4 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -4,7 +4,7 @@ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'rea import { setInputSelection } from '../app/inputSelectionStore.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' 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)) } -export function offsetFromPosition(value: string, row: number, col: number, cols: number) { - 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 -} +export { offsetFromPosition } function renderWithCursor(value: string, cursor: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -1059,7 +1009,7 @@ export function TextInput({ ref={boxRef} width={columns} > - {rendered} + {rendered} ) } diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 3d824be3ea..245baae96f 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -5,50 +5,153 @@ export const COMPOSER_PROMPT_GAP_WIDTH = 1 let _seg: Intl.Segmenter | null = null 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. */ export function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) const w = Math.max(1, cols) + const lines = visualLines(value, w) + let lineIndex = 0 - let col = 0, - line = 0 - - for (const { segment, index } of seg().segment(value)) { - if (index >= pos) { + for (let i = 0; i < lines.length; i += 1) { + if (lines[i]!.start <= pos) { + lineIndex = i + } else { break } - - if (segment === '\n') { - line++ - col = 0 - - continue - } - - const sw = stringWidth(segment) - - if (!sw) { - continue - } - - if (col + sw > w) { - line++ - col = 0 - } - - col += sw } + const line = lines[lineIndex]! + let column = widthBetween(value, line.start, Math.min(pos, line.end)) + // trailing cursor-cell overflows to the next row at the wrap column - if (col >= w) { - line++ - col = 0 + if (column >= w) { + lineIndex++ + 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) {