mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
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:
parent
5e6e8b6af3
commit
98f5be13fa
4 changed files with 170 additions and 96 deletions
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue