mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-25 05:52:34 +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
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue