hermes-agent/ui-tui/src/lib/inputMetrics.ts
asheriif 0ce1b9fe20
fix(tui): preserve prompt separator width (#19340)
* fix(tui): preserve prompt separator width

* fix(tui): align transcript height estimates with prompt width
2026-05-04 09:58:40 -07:00

181 lines
5.1 KiB
TypeScript

import { stringWidth } from '@hermes/ink'
import type { Role } from '../types.js'
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 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
for (let i = 0; i < lines.length; i += 1) {
if (lines[i]!.start <= pos) {
lineIndex = i
} else {
break
}
}
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 (column >= w) {
lineIndex++
column = 0
}
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) {
return cursorLayout(value, value.length, columns).line + 1
}
export function composerPromptWidth(promptText: string) {
return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH
}
export function transcriptGutterWidth(role: Role, userPrompt: string) {
return role === 'user' ? composerPromptWidth(userPrompt) : 3
}
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) {
return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt) - 2)
}
export function stableComposerColumns(totalCols: number, promptWidth: number) {
// Physical render/wrap width. Always reserve outer composer padding and
// prompt prefix. Only reserve the transcript scrollbar gutter when the
// terminal is wide enough; on narrow panes, preserving input columns beats
// keeping gutters visually aligned.
return Math.max(1, totalCols - promptWidth - 2 - (totalCols - promptWidth >= 24 ? 2 : 0))
}