Skip to content

Commit 9010f37

Browse files
yanmxaclaude
andcommitted
fix(render): eliminate extra blank lines and Chroma error highlighting
- Override Document.BlockPrefix/BlockSuffix to remove leading empty line - Clear Heading.BlockSuffix to remove extra blank line after headings - Add collapseBlankLines post-processor for glamour hardcoded newlines - Apply TrimLeft to remove leading newlines from first-element blocks - Remove redundant newline prefixes in View() part composition - Clear Chroma Error style to prevent red background on tree characters - Add tests for no-leading-blank-line and no-consecutive-blank-lines Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> Signed-off-by: Meng Yan <myan@redhat.com>
1 parent 61bbbee commit 9010f37

5 files changed

Lines changed: 184 additions & 37 deletions

File tree

cmd/rendercheck/main.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/yanmxa/gencode/internal/app/render"
8+
)
9+
10+
func main() {
11+
md := render.NewMDRenderer(80)
12+
13+
tests := []struct {
14+
name string
15+
content string
16+
}{
17+
{"Simple paragraph", "This is a simple paragraph with some text."},
18+
{"Two paragraphs", "First paragraph.\n\nSecond paragraph."},
19+
{"Heading + paragraph", "## Title\n\nSome content here."},
20+
{"List items", "Here are items:\n\n- Item 1\n- Item 2\n- Item 3"},
21+
{"Ordered list", "Steps:\n\n1. First step\n2. Second step\n3. Third step"},
22+
{"Code block", "Here is code:\n\n```go\nfunc main() {\n fmt.Println(\"hello\")\n}\n```\n\nAfter code."},
23+
{"Inline code", "Use `fmt.Println` to print."},
24+
{"Nested list", "- Parent 1\n - Child 1a\n - Child 1b\n- Parent 2"},
25+
{"Blockquote", "> This is a quote\n> spanning multiple lines"},
26+
{"Bold and italic", "This is **bold** and *italic* text."},
27+
{"CJK text", "这是一段中文文本,\n用于测试换行效果。"},
28+
{"Heading + list + code", "## Summary\n\nKey changes:\n\n- Added `foo` function\n- Fixed bug in `bar`\n\n```go\nfunc foo() {}\n```"},
29+
{"Multi heading", "## Section 1\n\nContent 1.\n\n## Section 2\n\nContent 2."},
30+
{"List then paragraph", "- Item 1\n- Item 2\n\nSome text after list."},
31+
{"Paragraph then list", "Some text before list.\n\n- Item 1\n- Item 2"},
32+
}
33+
34+
for _, tt := range tests {
35+
fmt.Printf("=== %s ===\n", tt.name)
36+
result, err := md.Render(tt.content)
37+
if err != nil {
38+
fmt.Printf("ERROR: %v\n", err)
39+
continue
40+
}
41+
lines := strings.Split(result, "\n")
42+
for i, line := range lines {
43+
fmt.Printf("%2d|%s|\n", i, line)
44+
}
45+
fmt.Println()
46+
}
47+
}

internal/app/render/markdown.go

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package render
55

66
import (
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.
2630
func 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).
303309
func 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+
}

internal/app/render/markdown_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,53 @@ func TestMDRenderer_Table(t *testing.T) {
350350
t.Errorf("table should have row separators ─, got:\n%s", plain)
351351
}
352352
}
353+
354+
func TestMDRenderer_NoLeadingBlankLine(t *testing.T) {
355+
r := NewMDRenderer(80)
356+
tests := []struct {
357+
name string
358+
input string
359+
}{
360+
{"paragraph", "Hello world."},
361+
{"heading", "## Title"},
362+
{"list", "- item 1\n- item 2"},
363+
{"code block", "```go\nfunc main() {}\n```"},
364+
}
365+
for _, tt := range tests {
366+
t.Run(tt.name, func(t *testing.T) {
367+
out, err := r.Render(tt.input)
368+
if err != nil {
369+
t.Fatalf("Render error: %v", err)
370+
}
371+
if strings.HasPrefix(out, "\n") {
372+
t.Errorf("output should not start with blank line, got:\n%q", out)
373+
}
374+
})
375+
}
376+
}
377+
378+
func TestMDRenderer_NoConsecutiveBlankLines(t *testing.T) {
379+
r := NewMDRenderer(80)
380+
tests := []struct {
381+
name string
382+
input string
383+
}{
384+
{"heading + paragraph", "## Title\n\nSome content here."},
385+
{"paragraph + list", "Here are items:\n\n- Item 1\n- Item 2"},
386+
{"paragraph + code", "Code:\n\n```go\nfmt.Println(\"hi\")\n```"},
387+
{"heading + list", "## Summary\n\n- Item 1\n- Item 2"},
388+
{"multi section", "## S1\n\nContent 1.\n\n## S2\n\nContent 2."},
389+
}
390+
for _, tt := range tests {
391+
t.Run(tt.name, func(t *testing.T) {
392+
out, err := r.Render(tt.input)
393+
if err != nil {
394+
t.Fatalf("Render error: %v", err)
395+
}
396+
if strings.Contains(out, "\n\n\n") {
397+
plain := stripANSI(out)
398+
t.Errorf("output should not contain consecutive blank lines, got:\n%s", plain)
399+
}
400+
})
401+
}
402+
}

internal/app/render/message.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ func TokenUsageColorAndHint(percent float64) (lipgloss.TerminalColor, string) {
153153
}
154154

155155
// RenderUserMessage renders a user message with prompt and optional images.
156-
func RenderUserMessage(content string, images []message.ImageData, mdRenderer *MDRenderer) string {
156+
func RenderUserMessage(content string, images []message.ImageData, mdRenderer *MDRenderer, width int) string {
157157
var sb strings.Builder
158158
prompt := InputPromptStyle.Render("❯ ")
159159

@@ -166,9 +166,8 @@ func RenderUserMessage(content string, images []message.ImageData, mdRenderer *M
166166
sb.WriteString(prompt + strings.Join(parts, " ") + "\n")
167167
}
168168

169-
// Render text content
170169
if content != "" {
171-
sb.WriteString(prompt + UserMsgStyle.Render(content) + "\n")
170+
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, prompt, UserMsgStyle.Render(content)) + "\n")
172171
}
173172

174173
return sb.String()
@@ -233,30 +232,24 @@ func RenderAssistantMessage(params AssistantParams) string {
233232
if params.StreamActive && params.IsLast {
234233
aiIcon = AIPromptStyle.Render(params.SpinnerView + " ")
235234
}
236-
aiIndent := " "
237235

238236
// Display thinking content (reasoning_content) if available
239237
if params.Thinking != "" {
240238
wrapWidth := max(params.Width-2, MinWrapWidth)
241239
wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(params.Thinking)
242-
243240
var lines []string
244241
for _, line := range strings.Split(wrapped, "\n") {
245242
if strings.TrimSpace(line) != "" {
246243
lines = append(lines, ThinkingStyle.Render(line))
247244
}
248245
}
249-
250246
thinkingIcon := ThinkingStyle.Render("✦ ")
251-
thinkingContent := strings.Join(lines, "\n"+aiIndent)
252-
sb.WriteString(thinkingIcon + thinkingContent + "\n\n")
247+
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, thinkingIcon, strings.Join(lines, "\n")) + "\n\n")
253248
}
254249

255-
// Render content based on streaming state
256250
content := FormatAssistantContent(params)
257251
if content != "" {
258-
content = strings.ReplaceAll(content, "\n", "\n"+aiIndent)
259-
sb.WriteString(aiIcon + content + "\n")
252+
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, aiIcon, content) + "\n")
260253
}
261254

262255
return sb.String()
@@ -345,7 +338,7 @@ func RenderToolCalls(params ToolCallsParams) string {
345338
label := FormatAgentLabel(tc.Input)
346339
_, hasResult := params.ResultMap[tc.ID]
347340
if hasResult {
348-
sb.WriteString(ToolCallStyle.Render(fmt.Sprintf("● %s", label)) + "\n")
341+
sb.WriteString(renderToolLine(label) + "\n")
349342
} else {
350343
sb.WriteString(ToolCallStyle.Render(fmt.Sprintf("%s %s", params.SpinnerView, label)))
351344
if !params.ToolCallsExpanded {
@@ -359,7 +352,7 @@ func RenderToolCalls(params ToolCallsParams) string {
359352
sb.WriteString(formatAgentDefinition(tc.Input))
360353
}
361354
} else if params.ToolCallsExpanded {
362-
toolLine := ToolCallStyle.Render(fmt.Sprintf("● %s", tc.Name))
355+
toolLine := renderToolLine(tc.Name)
363356
sb.WriteString(toolLine + "\n")
364357
var p map[string]any
365358
if err := json.Unmarshal([]byte(tc.Input), &p); err == nil {
@@ -376,14 +369,11 @@ func RenderToolCalls(params ToolCallsParams) string {
376369
}
377370
} else {
378371
if tc.Name == tool.ToolTaskGet && params.TaskOwnerMap != nil {
379-
// Show owner name instead of raw task ID
380372
args := extractTaskGetDisplay(tc.Input, params.TaskOwnerMap)
381-
toolLine := ToolCallStyle.Render(fmt.Sprintf("● %s(%s)", tc.Name, args))
382-
sb.WriteString(toolLine + "\n")
373+
sb.WriteString(renderToolLine(fmt.Sprintf("%s(%s)", tc.Name, args)) + "\n")
383374
} else {
384375
args := ExtractToolArgs(tc.Input)
385-
toolLine := ToolCallStyle.Render(fmt.Sprintf("● %s(%s)", tc.Name, args))
386-
sb.WriteString(toolLine + "\n")
376+
sb.WriteString(renderToolLine(fmt.Sprintf("%s(%s)", tc.Name, args)) + "\n")
387377
}
388378
}
389379

@@ -1000,3 +990,10 @@ func FormatTokenCount(count int) string {
1000990
return fmt.Sprintf("%d", count)
1001991
}
1002992
}
993+
994+
// renderToolLine renders a tool call line with a bullet icon, where continuation
995+
// lines are automatically indented to align after the icon via JoinHorizontal.
996+
func renderToolLine(label string) string {
997+
icon := ToolCallStyle.Render("● ")
998+
return lipgloss.JoinHorizontal(lipgloss.Top, icon, ToolCallStyle.Render(label))
999+
}

internal/app/view.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (m model) View() string {
5757
}
5858

5959
if todoView != "" {
60-
parts = append(parts, "\n"+strings.TrimSuffix(todoView, "\n"))
60+
parts = append(parts, strings.TrimSuffix(todoView, "\n"))
6161
}
6262

6363
if pendingImagesView != "" {
@@ -66,12 +66,12 @@ func (m model) View() string {
6666

6767
if m.provider.FetchingLimits {
6868
spinnerView := render.ThinkingStyle.Render(m.output.Spinner.View() + " Fetching token limits...")
69-
parts = append(parts, "\n"+spinnerView)
69+
parts = append(parts, spinnerView)
7070
}
7171

7272
if m.conv.Compact.Active {
7373
spinnerView := render.ThinkingStyle.Render(m.output.Spinner.View() + " Compacting conversation...")
74-
parts = append(parts, "\n"+spinnerView)
74+
parts = append(parts, spinnerView)
7575
}
7676

7777
chatSection := strings.Join(parts, "\n")
@@ -292,7 +292,7 @@ func (m model) renderMessageRange(startIdx, endIdx int, includeSpinner bool) str
292292
}
293293

294294
func (m model) renderUserMessage(msg message.ChatMessage) string {
295-
return render.RenderUserMessage(msg.Content, msg.Images, m.output.MDRenderer)
295+
return render.RenderUserMessage(msg.Content, msg.Images, m.output.MDRenderer, m.width)
296296
}
297297

298298
func (m model) renderToolResult(msg message.ChatMessage) string {

0 commit comments

Comments
 (0)