mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
fix(tui): trim markdown wrap spaces (#22062)
* 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.
This commit is contained in:
parent
78b0008f44
commit
a7e7921dbc
5 changed files with 147 additions and 68 deletions
|
|
@ -260,23 +260,6 @@ function applyStylesToWrappedText(
|
|||
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
||||
const line = lines[lineIdx]!
|
||||
|
||||
// In trim mode, skip leading whitespace that was trimmed from this line.
|
||||
// Only skip if the original has whitespace but the output line doesn't start
|
||||
// with whitespace (meaning it was trimmed). If both have whitespace, the
|
||||
// whitespace was preserved and we shouldn't skip.
|
||||
if (trimEnabled && line.length > 0) {
|
||||
const lineStartsWithWhitespace = /\s/.test(line[0]!)
|
||||
|
||||
const originalHasWhitespace = charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)
|
||||
|
||||
// Only skip if original has whitespace but line doesn't
|
||||
if (originalHasWhitespace && !lineStartsWithWhitespace) {
|
||||
while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) {
|
||||
charIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let styledLine = ''
|
||||
let runStart = 0
|
||||
let runSegmentIndex = charToSegment[charIndex] ?? 0
|
||||
|
|
@ -333,26 +316,10 @@ function applyStylesToWrappedText(
|
|||
// split lines.
|
||||
if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') {
|
||||
charIndex++
|
||||
}
|
||||
|
||||
// In trim mode, skip whitespace that was replaced by newline when wrapping.
|
||||
// We skip whitespace in the original until we reach a character that matches
|
||||
// the first character of the next line. This handles cases like:
|
||||
// - "AB \tD" wrapped to "AB\n\tD" - skip spaces until we hit the tab
|
||||
// In non-trim mode, whitespace is preserved so no skipping is needed.
|
||||
if (trimEnabled && lineIdx < lines.length - 1) {
|
||||
const nextLine = lines[lineIdx + 1]!
|
||||
const nextLineFirstChar = nextLine.length > 0 ? nextLine[0] : null
|
||||
|
||||
// Skip whitespace until we hit a char that matches the next line's first char
|
||||
while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) {
|
||||
// Stop if we found the character that starts the next line
|
||||
if (nextLineFirstChar !== null && originalPlain[charIndex] === nextLineFirstChar) {
|
||||
break
|
||||
}
|
||||
|
||||
charIndex++
|
||||
}
|
||||
} else if (trimEnabled && lineIdx < lines.length - 1 && /\s/.test(originalPlain[charIndex] ?? '')) {
|
||||
// wrap-trim removes exactly one whitespace character at each soft-wrap boundary.
|
||||
// Keep the style map aligned without eating preserved indentation/spaces.
|
||||
charIndex++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
17
ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts
Normal file
17
ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import wrapText from './wrap-text.js'
|
||||
|
||||
describe('wrapText wrap-trim', () => {
|
||||
it('removes a single soft-wrap boundary space', () => {
|
||||
expect(wrapText('Let me', 5, 'wrap-trim')).toBe('Let\nme')
|
||||
})
|
||||
|
||||
it('preserves extra original spacing at soft-wrap boundaries', () => {
|
||||
expect(wrapText('foo bar', 5, 'wrap-trim')).toBe('foo \nbar')
|
||||
})
|
||||
|
||||
it('preserves leading whitespace on unwrapped source lines', () => {
|
||||
expect(wrapText(' indented', 20, 'wrap-trim')).toBe(' indented')
|
||||
})
|
||||
})
|
||||
|
|
@ -77,6 +77,32 @@ function truncate(text: string, columns: number, position: 'start' | 'middle' |
|
|||
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 })
|
||||
|
|
@ -87,7 +113,7 @@ function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap'
|
|||
}
|
||||
|
||||
if (wrapType === 'wrap-trim') {
|
||||
return wrapAnsi(text, maxWidth, { trim: true, hard: true })
|
||||
return trimSoftWrapBoundaries(text, maxWidth)
|
||||
}
|
||||
|
||||
if (wrapType!.startsWith('truncate')) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,47 @@
|
|||
import { PassThrough } from 'stream'
|
||||
|
||||
import { Box, renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AUDIO_DIRECTIVE_RE, INLINE_RE, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
|
||||
import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const matches = (text: string) => [...text.matchAll(INLINE_RE)].map(m => m[0])
|
||||
const BEL = String.fromCharCode(7)
|
||||
const ESC = String.fromCharCode(27)
|
||||
const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
||||
const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
||||
|
||||
const renderPlain = (node: React.ReactNode) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(node, {
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return output
|
||||
.replace(OSC_RE, '')
|
||||
.split('\n')
|
||||
.map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd())
|
||||
}
|
||||
|
||||
describe('INLINE_RE emphasis', () => {
|
||||
it('matches word-boundary italic/bold', () => {
|
||||
|
|
@ -144,3 +183,37 @@ describe('protocol sentinels', () => {
|
|||
expect(AUDIO_DIRECTIVE_RE.test('audio_as_voice')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Md wrapping', () => {
|
||||
it('trims spaces from word-wrap continuation lines', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(Box, { width: 5 }, React.createElement(Md, { t: DEFAULT_THEME, text: 'Let me' }))
|
||||
)
|
||||
|
||||
expect(lines).toContain('Let')
|
||||
expect(lines).toContain('me')
|
||||
expect(lines).not.toContain(' me')
|
||||
})
|
||||
|
||||
it('keeps nested list and quote indentation out of trim-sensitive text', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column', width: 24 },
|
||||
React.createElement(Md, { t: DEFAULT_THEME, text: ' - nested bullet' }),
|
||||
React.createElement(Md, { t: DEFAULT_THEME, text: '>> nested quote' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(lines).toContain(' • nested bullet')
|
||||
expect(lines).toContain(' │ nested quote')
|
||||
})
|
||||
|
||||
it('preserves original inline-code edge spaces', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(Box, { width: 24 }, React.createElement(Md, { t: DEFAULT_THEME, text: '` hi ` ok' }))
|
||||
)
|
||||
|
||||
expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
|||
parts.push(<Text key={parts.length}>{text.slice(last)}</Text>)
|
||||
}
|
||||
|
||||
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
||||
return <Text wrap="wrap-trim">{parts.length ? parts : text}</Text>
|
||||
}
|
||||
|
||||
// Cross-instance parsed-children cache: useMemo's per-instance cache dies
|
||||
|
|
@ -420,7 +420,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (media) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.muted} key={key}>
|
||||
<Text color={t.color.muted} key={key} wrap="wrap-trim">
|
||||
{'▸ '}
|
||||
|
||||
<Link url={/^(?:\/|[a-z]:[\\/])/i.test(media) ? `file://${media}` : media}>
|
||||
|
|
@ -594,7 +594,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.accent} key={key}>
|
||||
<Text bold color={t.color.accent} key={key} wrap="wrap-trim">
|
||||
<MdInline t={t} text={heading} />
|
||||
</Text>
|
||||
)
|
||||
|
|
@ -606,7 +606,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.accent} key={key}>
|
||||
<Text bold color={t.color.accent} key={key} wrap="wrap-trim">
|
||||
<MdInline t={t} text={line.trim()} />
|
||||
</Text>
|
||||
)
|
||||
|
|
@ -632,7 +632,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.muted} key={key}>
|
||||
<Text color={t.color.muted} key={key} wrap="wrap-trim">
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
|
|
@ -641,7 +641,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.muted}>
|
||||
<Text color={t.color.muted} wrap="wrap-trim">
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -655,7 +655,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
<Text bold key={key} wrap="wrap-trim">
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
|
|
@ -669,7 +669,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
}
|
||||
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text key={`${key}-def-${i}`} wrap="wrap-trim">
|
||||
<Text color={t.color.muted}> · </Text>
|
||||
<MdInline t={t} text={def} />
|
||||
</Text>
|
||||
|
|
@ -689,14 +689,12 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '.repeat(indentDepth(bullet[1]!) * 2)}
|
||||
{marker}{' '}
|
||||
<Box key={key} paddingLeft={indentDepth(bullet[1]!) * 2}>
|
||||
<Text wrap="wrap-trim">
|
||||
<Text color={t.color.muted}>{marker} </Text>
|
||||
<MdInline t={t} text={task ? task[2]! : bullet[2]!} />
|
||||
</Text>
|
||||
|
||||
<MdInline t={t} text={task ? task[2]! : bullet[2]!} />
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
|
||||
|
|
@ -708,14 +706,12 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (numbered) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.muted}>
|
||||
{' '.repeat(indentDepth(numbered[1]!) * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
<Box key={key} paddingLeft={indentDepth(numbered[1]!) * 2}>
|
||||
<Text wrap="wrap-trim">
|
||||
<Text color={t.color.muted}>{numbered[2]}. </Text>
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
</Text>
|
||||
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
|
||||
|
|
@ -737,11 +733,11 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
nodes.push(
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.muted} key={qi}>
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
</Text>
|
||||
<Box key={qi} paddingLeft={Math.max(0, ql.depth - 1) * 2}>
|
||||
<Text color={t.color.muted} wrap="wrap-trim">
|
||||
│ <MdInline t={t} text={ql.text} />
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
|
@ -774,7 +770,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.muted} key={key}>
|
||||
<Text color={t.color.muted} key={key} wrap="wrap-trim">
|
||||
▶ {summary}
|
||||
</Text>
|
||||
)
|
||||
|
|
@ -786,7 +782,7 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.muted} key={key}>
|
||||
<Text color={t.color.muted} key={key} wrap="wrap-trim">
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue