mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-24 05:41:40 +00:00
* fix(tui): trim markdown wrap spaces Use trim-aware wrapping for markdown prose so word-wrapped continuation lines do not keep boundary spaces. * fix(tui): simplify markdown wrap nodes Keep trim-aware wrapping on the rendered markdown text node while leaving nested inline segments as plain virtual text. * fix(tui): trim definition row wrapping Apply trim-aware wrapping to markdown definition rows so continuation lines match other prose rows. * fix(tui): trim list and quote wrapping Put trim-aware wrapping on the rendered list and quote rows that own markdown inline layout. * fix(tui): preserve markdown nesting with trim wrap Move list and quote indentation into layout padding so trim-aware wrapping does not erase nested markdown structure. * fix(tui): trim only soft wrap spaces Change trim-aware wrapping to remove whitespace only at soft-wrap boundaries so original leading inline spaces stay verbatim. * fix(tui): preserve extra boundary whitespace Trim only one soft-wrap boundary whitespace character so wrap-trim avoids leading continuations without collapsing intentional spacing. * fix(tui): align styled wrap-trim mapping Update styled text remapping to skip the single whitespace removed at soft-wrap boundaries without dropping preserved indentation. * fix(tui): clean wrap trim test helpers Clarify boundary-trim wording and strip OSC escapes from markdown render test output. * fix(tui): strip osc before ansi in markdown tests Remove OSC escapes from raw render output before SGR/CSI cleanup so markdown render assertions stay plain text.
144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
import sliceAnsi from '../utils/sliceAnsi.js'
|
|
|
|
import { lruEvict } from './lru.js'
|
|
import { stringWidth } from './stringWidth.js'
|
|
import type { Styles } from './styles.js'
|
|
import { wrapAnsi } from './wrapAnsi.js'
|
|
|
|
const ELLIPSIS = '…'
|
|
|
|
// CPU profile (Apr 2026) showed `wrap-ansi` → `string-width` consuming 30% of
|
|
// total runtime during fast scroll: every layout pass re-wraps every visible
|
|
// line via wrap-ansi, which calls string-width once per grapheme. The output
|
|
// is pure of (text, maxWidth, wrapType), so memoize it. LRU-bounded so long
|
|
// sessions don't accrete unbounded cache.
|
|
const WRAP_CACHE_LIMIT = 4096
|
|
const wrapCache = new Map<string, string>()
|
|
|
|
function memoizedWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
|
// Key folds maxWidth + wrapType into the prefix so the same text re-wrapped
|
|
// at a different width doesn't collide. Width prefix bounded by viewport
|
|
// (~10 distinct widths in a session); wrapType bounded by enum (~6 values).
|
|
const key = `${maxWidth}|${wrapType}|${text}`
|
|
const cached = wrapCache.get(key)
|
|
|
|
if (cached !== undefined) {
|
|
// LRU touch
|
|
wrapCache.delete(key)
|
|
wrapCache.set(key, cached)
|
|
|
|
return cached
|
|
}
|
|
|
|
const result = computeWrap(text, maxWidth, wrapType)
|
|
|
|
if (wrapCache.size >= WRAP_CACHE_LIMIT) {
|
|
wrapCache.delete(wrapCache.keys().next().value!)
|
|
}
|
|
|
|
wrapCache.set(key, result)
|
|
|
|
return result
|
|
}
|
|
|
|
// sliceAnsi may include a boundary-spanning wide char (e.g. CJK at position
|
|
// end-1 with width 2 overshoots by 1). Retry with a tighter bound once.
|
|
function sliceFit(text: string, start: number, end: number): string {
|
|
const s = sliceAnsi(text, start, end)
|
|
|
|
return stringWidth(s) > end - start ? sliceAnsi(text, start, end - 1) : s
|
|
}
|
|
|
|
function truncate(text: string, columns: number, position: 'start' | 'middle' | 'end'): string {
|
|
if (columns < 1) {
|
|
return ''
|
|
}
|
|
|
|
if (columns === 1) {
|
|
return ELLIPSIS
|
|
}
|
|
|
|
const length = stringWidth(text)
|
|
|
|
if (length <= columns) {
|
|
return text
|
|
}
|
|
|
|
if (position === 'start') {
|
|
return ELLIPSIS + sliceFit(text, length - columns + 1, length)
|
|
}
|
|
|
|
if (position === 'middle') {
|
|
const half = Math.floor(columns / 2)
|
|
|
|
return sliceFit(text, 0, half) + ELLIPSIS + sliceFit(text, length - (columns - half) + 1, length)
|
|
}
|
|
|
|
return sliceFit(text, 0, columns - 1) + ELLIPSIS
|
|
}
|
|
|
|
function trimSoftWrapBoundaries(text: string, maxWidth: number): string {
|
|
return text
|
|
.split('\n')
|
|
.map(line => {
|
|
const pieces = wrapAnsi(line, maxWidth, { trim: false, hard: true }).split('\n')
|
|
|
|
if (pieces.length === 1) {
|
|
return pieces[0]!
|
|
}
|
|
|
|
for (let index = 0; index < pieces.length - 1; index++) {
|
|
const current = pieces[index]!
|
|
const next = pieces[index + 1]!
|
|
|
|
if (/\s$/.test(current)) {
|
|
pieces[index] = current.replace(/\s$/, '')
|
|
} else if (/^\s/.test(next)) {
|
|
pieces[index + 1] = next.replace(/^\s/, '')
|
|
}
|
|
}
|
|
|
|
return pieces.join('\n')
|
|
})
|
|
.join('\n')
|
|
}
|
|
|
|
function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
|
if (wrapType === 'wrap') {
|
|
return wrapAnsi(text, maxWidth, { trim: false, hard: true })
|
|
}
|
|
|
|
if (wrapType === 'wrap-char') {
|
|
return wrapAnsi(text, maxWidth, { trim: false, hard: true, wordWrap: false })
|
|
}
|
|
|
|
if (wrapType === 'wrap-trim') {
|
|
return trimSoftWrapBoundaries(text, maxWidth)
|
|
}
|
|
|
|
if (wrapType!.startsWith('truncate')) {
|
|
const position: 'end' | 'middle' | 'start' =
|
|
wrapType === 'truncate-middle' ? 'middle' : wrapType === 'truncate-start' ? 'start' : 'end'
|
|
|
|
return truncate(text, maxWidth, position)
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
export default function wrapText(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
|
// Skip cache for trivial inputs (faster than Map lookup).
|
|
if (!text || maxWidth <= 0) {
|
|
return computeWrap(text, maxWidth, wrapType)
|
|
}
|
|
|
|
return memoizedWrap(text, maxWidth, wrapType)
|
|
}
|
|
|
|
export function wrapCacheSize(): number {
|
|
return wrapCache.size
|
|
}
|
|
|
|
export function evictWrapCache(keepRatio = 0): void {
|
|
lruEvict(wrapCache, keepRatio)
|
|
}
|