diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
index 50c9241c5d0..a31753c722a 100644
--- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts
@@ -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++
}
}
diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts
new file mode 100644
index 00000000000..8ccc31d9c96
--- /dev/null
+++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.test.ts
@@ -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')
+ })
+})
diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts
index dcc897b34f8..72574fa90c0 100644
--- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts
@@ -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')) {
diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts
index a415668f461..30706f6b09d 100644
--- a/ui-tui/src/__tests__/markdown.test.ts
+++ b/ui-tui/src/__tests__/markdown.test.ts
@@ -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)
+ })
+})
diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx
index 163768a51c3..d736af144ed 100644
--- a/ui-tui/src/components/markdown.tsx
+++ b/ui-tui/src/components/markdown.tsx
@@ -323,7 +323,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
parts.push({text.slice(last)})
}
- return {parts.length ? parts : {text}}
+ return {parts.length ? parts : 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(
-
+
{'▸ '}
@@ -594,7 +594,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (heading) {
start('heading')
nodes.push(
-
+
)
@@ -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(
-
+
)
@@ -632,7 +632,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (footnote) {
start('list')
nodes.push(
-
+
[{footnote[1]}]
)
@@ -641,7 +641,7 @@ function MdImpl({ compact, t, text }: MdProps) {
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
nodes.push(
-
+
@@ -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(
-
+
{line.trim()}
)
@@ -669,7 +669,7 @@ function MdImpl({ compact, t, text }: MdProps) {
}
nodes.push(
-
+
·
@@ -689,14 +689,12 @@ function MdImpl({ compact, t, text }: MdProps) {
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
nodes.push(
-
-
- {' '.repeat(indentDepth(bullet[1]!) * 2)}
- {marker}{' '}
+
+
+ {marker}
+
-
-
-
+
)
i++
@@ -708,14 +706,12 @@ function MdImpl({ compact, t, text }: MdProps) {
if (numbered) {
start('list')
nodes.push(
-
-
- {' '.repeat(indentDepth(numbered[1]!) * 2)}
- {numbered[2]}.{' '}
+
+
+ {numbered[2]}.
+
-
-
-
+
)
i++
@@ -737,11 +733,11 @@ function MdImpl({ compact, t, text }: MdProps) {
nodes.push(
{quoteLines.map((ql, qi) => (
-
- {' '.repeat(Math.max(0, ql.depth - 1) * 2)}
- {'│ '}
-
-
+
+
+ │
+
+
))}
)
@@ -774,7 +770,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (summary) {
start('paragraph')
nodes.push(
-
+
▶ {summary}
)
@@ -786,7 +782,7 @@ function MdImpl({ compact, t, text }: MdProps) {
if (/^<\/?[^>]+>$/.test(line.trim())) {
start('paragraph')
nodes.push(
-
+
{line.trim()}
)