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()} )