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) {