@@ -5,6 +5,7 @@ package render
55
66import (
77 "strings"
8+ "unicode/utf8"
89
910 "github.com/charmbracelet/glamour"
1011 "github.com/charmbracelet/glamour/ansi"
@@ -22,7 +23,10 @@ type MDRenderer struct {
2223 darkBg bool // tracks last known terminal background to detect theme changes
2324}
2425
25- // NewMDRenderer creates a new markdown renderer with the given width.
26+ // NewMDRenderer creates a new markdown renderer with the given terminal width.
27+ // The width passed should be the raw terminal column count; the renderer
28+ // subtracts aiIndentWidth internally so glamour wraps exactly at the
29+ // visible boundary after the "● " prompt icon + indent are applied.
2630func NewMDRenderer (width int ) * MDRenderer {
2731 w := max (width - 4 , MinWrapWidth )
2832 dark := theme .IsDarkBackground ()
@@ -81,12 +85,15 @@ func (r *MDRenderer) Render(content string) (string, error) {
8185 if err != nil {
8286 parts = append (parts , seg .content )
8387 } else {
84- parts = append (parts , strings .TrimRight (rendered , "\n " ))
88+ rendered = collapseBlankLines (rendered )
89+ parts = append (parts , strings .TrimRight (rendered , "\n " ))
8590 }
8691 }
8792 }
8893
89- return strings .TrimRight (strings .Join (parts , "" ), "\n " ), nil
94+ result := strings .TrimRight (strings .Join (parts , "" ), "\n " )
95+ result = strings .TrimLeft (result , "\n " )
96+ return result , nil
9097}
9198
9299// segmentKind identifies what type of markdown block a segment contains.
@@ -299,12 +306,20 @@ func adaptiveColorHex(c lipgloss.AdaptiveColor) string {
299306}
300307
301308// customizeStyle adjusts glamour's default style for a clean, unified look.
302- // Uses only 3 accent colors: blue (keywords/headings), green (strings/functions), muted (comments).
303309func customizeStyle (s * ansi.StyleConfig , width int ) {
304310 blue := adaptiveColorHex (theme .CurrentTheme .Primary )
305311 muted := adaptiveColorHex (theme .CurrentTheme .Muted )
312+ text := adaptiveColorHex (theme .CurrentTheme .Text )
313+ textDim := adaptiveColorHex (theme .CurrentTheme .TextDim )
306314
307- // Headings: blue, bold, no prefix markers
315+ // Document: set foreground color, no margin (paragraph spacing handled by glamour block prefix/suffix)
316+ margin := uint (0 )
317+ s .Document .Margin = & margin
318+ s .Document .StylePrimitive .Color = & text
319+ s .Document .BlockPrefix = ""
320+ s .Document .BlockSuffix = ""
321+
322+ // Headings: themed blue, bold, no extra prefix/suffix markers
308323 s .H1 .Prefix = ""
309324 s .H1 .Suffix = ""
310325 s .H1 .Color = & blue
@@ -316,32 +331,45 @@ func customizeStyle(s *ansi.StyleConfig, width int) {
316331 s .H3 .Prefix = ""
317332 s .H3 .Color = & blue
318333 s .H3 .Bold = boolPtr (true )
334+ s .Heading .BlockSuffix = ""
319335 s .H4 .Prefix = ""
320336 s .H5 .Prefix = ""
321337 s .H6 .Prefix = ""
322338
339+ // BlockQuote: muted color with standard │ indent token
340+ s .BlockQuote .StylePrimitive .Color = & textDim
341+ s .BlockQuote .Indent = uintPtr (1 )
342+ s .BlockQuote .IndentToken = stringPtr ("│ " )
343+
323344 // Horizontal rule: full-width thin line
324345 hr := strings .Repeat ("─" , width )
325346 s .HorizontalRule .Format = "\n " + hr + "\n "
326347 s .HorizontalRule .Color = & muted
327348
328- // Inline code: no background, just color distinction
349+ // Inline code: no background, accent color
350+ accent := adaptiveColorHex (theme .CurrentTheme .Accent )
329351 s .Code .StylePrimitive .BackgroundColor = nil
330352 s .Code .StylePrimitive .Prefix = ""
331353 s .Code .StylePrimitive .Suffix = ""
332- s .Code .StylePrimitive .Color = nil
354+ s .Code .StylePrimitive .Color = & accent
333355
334356 // Code blocks: remove Chroma background color for cleaner look
335357 if s .CodeBlock .Chroma != nil {
336358 s .CodeBlock .Chroma .Background = ansi.StylePrimitive {}
359+ s .CodeBlock .Chroma .Error = ansi.StylePrimitive {}
337360 }
338-
339- // Reduce document margin for tighter layout
340- margin := uint (0 )
341- s .Document .Margin = & margin
342361}
343362
344- func boolPtr (b bool ) * bool { return & b }
363+ func boolPtr (b bool ) * bool { return & b }
364+
365+ func collapseBlankLines (s string ) string {
366+ for strings .Contains (s , "\n \n \n " ) {
367+ s = strings .ReplaceAll (s , "\n \n \n " , "\n \n " )
368+ }
369+ return s
370+ }
371+ func uintPtr (u uint ) * uint { return & u }
372+ func stringPtr (s string ) * string { return & s }
345373
346374// normalizeLineBreaks joins single-newline breaks within plain paragraphs so
347375// that glamour's word-wrap can reflow text to the terminal width. Structural
@@ -388,14 +416,18 @@ func normalizeLineBreaks(content string) string {
388416 if i > 0 && len (result ) > 0 {
389417 prev := result [len (result )- 1 ]
390418 prevTrimmed := strings .TrimSpace (prev )
391- // Join if previous line is non-blank, non-structural, non-code-fence,
392- // not indented code, and doesn't end with a hard break (two trailing spaces)
393419 if prevTrimmed != "" &&
394420 ! strings .HasPrefix (prevTrimmed , "```" ) && ! strings .HasPrefix (prevTrimmed , "~~~" ) &&
395421 ! strings .HasPrefix (prev , " " ) && ! strings .HasPrefix (prev , "\t " ) &&
396422 ! isMarkdownStructural (prevTrimmed ) &&
397423 ! strings .HasSuffix (prev , " " ) {
398- result [len (result )- 1 ] = prev + " " + trimmed
424+ // Don't insert a space between CJK lines — Chinese/Japanese/Korean
425+ // text doesn't use spaces between words.
426+ sep := " "
427+ if endsWithCJK (prevTrimmed ) || startsWithCJK (trimmed ) {
428+ sep = ""
429+ }
430+ result [len (result )- 1 ] = prev + sep + trimmed
399431 continue
400432 }
401433 }
@@ -454,3 +486,24 @@ func isOrderedListItem(line string) bool {
454486 }
455487 return false
456488}
489+
490+ // isCJK reports whether r is a CJK (Chinese/Japanese/Korean) character.
491+ func isCJK (r rune ) bool {
492+ return (r >= 0x4E00 && r <= 0x9FFF ) || // CJK Unified Ideographs
493+ (r >= 0x3400 && r <= 0x4DBF ) || // CJK Extension A
494+ (r >= 0x20000 && r <= 0x2A6DF ) || // CJK Extension B
495+ (r >= 0x3000 && r <= 0x303F ) || // CJK Symbols and Punctuation
496+ (r >= 0xFF00 && r <= 0xFFEF ) // Halfwidth/Fullwidth Forms
497+ }
498+
499+ // endsWithCJK reports whether s ends with a CJK character.
500+ func endsWithCJK (s string ) bool {
501+ r , _ := utf8 .DecodeLastRuneInString (s )
502+ return r != utf8 .RuneError && isCJK (r )
503+ }
504+
505+ // startsWithCJK reports whether s starts with a CJK character.
506+ func startsWithCJK (s string ) bool {
507+ r , _ := utf8 .DecodeRuneInString (s )
508+ return r != utf8 .RuneError && isCJK (r )
509+ }
0 commit comments