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:
brooklyn! 2026-05-08 20:51:34 -07:00 committed by GitHub
parent 78b0008f44
commit a7e7921dbc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 147 additions and 68 deletions

View file

@ -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++
}
}

View 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')
})
})

View file

@ -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')) {