import { stringWidth, wrapAnsi } 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 graphemes = (value: string) => [...seg().segment(value)].map(({ segment, index }) => ({ end: index + segment.length, index, segment, width: Math.max(1, stringWidth(segment)) })) // Build VisualLines from wrap-ansi's output by mapping each emitted character // back to its original offset in `value`. wrap-ansi only INSERTS '\n' at wrap // boundaries — it never drops, reorders, or substitutes existing characters — // so a parallel walk uniquely identifies each line's source range. // // This used to be a hand-rolled word-wrap whose break points disagreed with // wrap-ansi in subtle but visible ways: exact-fill rows pushed the cursor to // a phantom next line, mid-word breaks landed one grapheme off, etc. The // composer's TextInput renders text via Ink's , which // delegates to wrap-ansi — so any drift between the two algorithms parks the // hardware cursor several cells away from the last rendered character. // Sourcing both from wrap-ansi guarantees agreement. function visualLines(value: string, cols: number): VisualLine[] { if (!value.length) { return [{ start: 0, end: 0 }] } const width = Math.max(1, cols) const wrapped = wrapAnsi(value, width, { hard: true, trim: false }) const lines: VisualLine[] = [] let originalIdx = 0 let lineStart = 0 for (let i = 0; i < wrapped.length; i += 1) { const ch = wrapped[i]! if (ch === '\n') { // wrap-ansi inserts '\n' to mark a soft-wrap boundary OR copies a // literal '\n' from the input. Either way the next char in `wrapped` // begins a new visual line. If the source character is a hard '\n', // consume it (it doesn't appear in either line). Otherwise the '\n' // is purely a wrap marker and originalIdx stays put. lines.push({ start: lineStart, end: originalIdx }) const isHardNewline = originalIdx < value.length && value[originalIdx] === '\n' if (isHardNewline) { originalIdx += 1 } lineStart = originalIdx continue } // Defensive sync check. wrap-ansi (with `hard: true, trim: false`, no // styled input) is documented to only insert '\n' at break points and // never substitute, drop, or reorder source characters — so under those // options `wrapped[i]` should always equal `value[originalIdx]`. But // future option changes, library upgrades, or callers that start passing // styled input (ANSI escapes) could violate that invariant silently. If // they do, we'd slide `originalIdx` past the end of `value` and emit // garbage line ranges with no diagnostic. Realign by scanning forward // for the matching character; bail out (return whatever we have) if the // sync is unrecoverable rather than producing wrong-but-plausible output. if (originalIdx >= value.length) { break } if (value[originalIdx] !== ch) { const reSync = value.indexOf(ch, originalIdx) if (reSync === -1) { break } originalIdx = reSync } originalIdx += 1 } lines.push({ start: lineStart, end: originalIdx }) // wrap-ansi collapses an empty input into [""] which we already handled // above; preserve the invariant that lines is never empty for any input. 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. * * IMPORTANT: this MUST stay in lock-step with how Ink's `` * lays the value out (which uses `wrap-ansi`). Any divergence parks the * hardware cursor several cells off the last rendered character — see the * "cursor drift past blank cells" bug. `visualLines` is sourced directly * from wrap-ansi to enforce that invariant. */ 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]! const column = widthBetween(value, line.start, Math.min(pos, line.end)) // NOTE: the previous implementation forced an extra line break when // `column >= w` (the "trailing cursor-cell overflows" rule). With // `visualLines` sourcing breaks from wrap-ansi, the line wrapping // above already matches what Ink will actually render. Pushing the // cursor onto a phantom next line here would re-introduce the same // drift we're fixing, so we don't. 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, termuxMode = false) { const horizontalReserve = termuxMode ? 2 : 4 const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - horizontalReserve) if (termuxMode) { // On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum // width causes right-edge clipping and chopped words. return available } return Math.max(20, available) } export function stableComposerColumns(totalCols: number, promptWidth: number, termuxMode = false) { // 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. const afterPrompt = totalCols - promptWidth const reserveScrollbar = afterPrompt >= (termuxMode ? 36 : 24) ? 2 : 0 return Math.max(1, totalCols - promptWidth - 2 - reserveScrollbar) }