From 0de38190d79792d79f352c7f9c869c7783fdd349 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Mon, 20 Jul 2026 11:25:43 -0700 Subject: [PATCH] cmd/tui/chat: render bold emphasis consistently across markdown (#17224) --- cmd/tui/chat/markdown.go | 187 ++++++++++++++++++++++++++++++------ cmd/tui/chat/render_test.go | 106 +++++++++++++++++++- cmd/tui/chat/theme.go | 3 + 3 files changed, 266 insertions(+), 30 deletions(-) diff --git a/cmd/tui/chat/markdown.go b/cmd/tui/chat/markdown.go index 9d617f590..00accac07 100644 --- a/cmd/tui/chat/markdown.go +++ b/cmd/tui/chat/markdown.go @@ -2,8 +2,11 @@ package chat import ( "strings" + "unicode" + "unicode/utf8" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" ) func renderMarkdownForView(markdown string, width int) string { @@ -34,7 +37,7 @@ func renderMarkdownForView(markdown string, width int) string { } if heading, ok := markdownHeading(trimmed); ok { - rendered = append(rendered, chatHeaderStyle.Render(heading)) + rendered = append(rendered, chatHeaderStyle.Render(renderMarkdownRunes(parseMarkdownInline(heading)))) continue } @@ -42,9 +45,7 @@ func renderMarkdownForView(markdown string, width int) string { rendered = append(rendered, "") continue } - for _, wrapped := range wrapChatText(line, width) { - rendered = append(rendered, renderMarkdownInline(wrapped)) - } + rendered = append(rendered, wrapMarkdownInline(line, width)...) } return strings.Join(rendered, "\n") } @@ -71,22 +72,148 @@ func markdownHeading(line string) (string, bool) { return strings.TrimSpace(line[level:]), true } -func renderMarkdownInline(line string) string { +type markdownInlineStyle uint8 + +const ( + markdownPlain markdownInlineStyle = iota + markdownStrong + markdownCode +) + +type markdownInlineRune struct { + r rune + style markdownInlineStyle +} + +// wrapMarkdownInline parses a complete source line before wrapping it. That +// keeps emphasis intact when its opening and closing delimiters land on +// different visual lines. +func wrapMarkdownInline(line string, width int) []string { + return wrapInlineRunes(parseMarkdownInline(line), width) +} + +func wrapInlineRunes(runes []markdownInlineRune, width int) []string { + if len(runes) == 0 { + return []string{""} + } + + var rendered []string + for len(runes) > 0 { + hardCut, spaceCut, currentWidth := 0, 0, 0 + for i, item := range runes { + nextWidth := currentWidth + runewidth.RuneWidth(item.r) + if nextWidth > width { + break + } + currentWidth = nextWidth + hardCut = i + 1 + if unicode.IsSpace(item.r) && currentWidth > width/2 { + spaceCut = i + } + } + cut := hardCut + if spaceCut > 0 { + cut = spaceCut + } + if cut == 0 { + cut = 1 + } + + lineRunes := trimMarkdownSpace(runes[:cut]) + rendered = append(rendered, renderMarkdownRunes(lineRunes)) + runes = trimMarkdownSpace(runes[cut:]) + } + return rendered +} + +func parseMarkdownInline(line string) []markdownInlineRune { + var out []markdownInlineRune + for len(line) > 0 { + if strings.HasPrefix(line, "`") { + if end := strings.Index(line[1:], "`"); end >= 0 { + out = appendMarkdownRunes(out, line[1:end+1], markdownCode) + line = line[end+2:] + continue + } + } + if (strings.HasPrefix(line, "**") || strings.HasPrefix(line, "__")) && canOpenMarkdownStrong(out) { + delimiter := line[:2] + if end := strings.Index(line[2:], delimiter); end >= 0 { + out = appendMarkdownRunes(out, line[2:end+2], markdownStrong) + line = line[end+4:] + continue + } + } + + r, size := utf8.DecodeRuneInString(line) + out = append(out, markdownInlineRune{r: r, style: markdownPlain}) + line = line[size:] + } + return out +} + +// canOpenMarkdownStrong keeps delimiter-like text in bare URLs and identifiers +// literal, only treating ** / __ as strong emphasis at the common +// whitespace- or punctuation-delimited form. +func canOpenMarkdownStrong(out []markdownInlineRune) bool { + if len(out) == 0 { + return true + } + previous := out[len(out)-1].r + return (unicode.IsSpace(previous) || unicode.IsPunct(previous)) && !markdownStrongInURL(out) +} + +func markdownStrongInURL(out []markdownInlineRune) bool { + start := len(out) + for start > 0 && !unicode.IsSpace(out[start-1].r) { + start-- + } + + var token strings.Builder + for _, item := range out[start:] { + token.WriteRune(item.r) + } + return strings.Contains(token.String(), "://") +} + +func appendMarkdownRunes(out []markdownInlineRune, text string, style markdownInlineStyle) []markdownInlineRune { + for _, r := range text { + out = append(out, markdownInlineRune{r: r, style: style}) + } + return out +} + +func trimMarkdownSpace(runes []markdownInlineRune) []markdownInlineRune { + start, end := 0, len(runes) + for start < end && unicode.IsSpace(runes[start].r) { + start++ + } + for end > start && unicode.IsSpace(runes[end-1].r) { + end-- + } + return runes[start:end] +} + +func renderMarkdownRunes(runes []markdownInlineRune) string { var b strings.Builder - for { - before, rest, ok := strings.Cut(line, "`") - b.WriteString(before) - if !ok { - break + for start := 0; start < len(runes); { + end := start + 1 + for end < len(runes) && runes[end].style == runes[start].style { + end++ } - code, after, ok := strings.Cut(rest, "`") - if !ok { - b.WriteString("`") - b.WriteString(rest) - break + var text strings.Builder + for _, item := range runes[start:end] { + text.WriteRune(item.r) } - b.WriteString(chatInlineCodeStyle.Render(code)) - line = after + switch runes[start].style { + case markdownStrong: + b.WriteString(chatStrongStyle.Render(text.String())) + case markdownCode: + b.WriteString(chatInlineCodeStyle.Render(text.String())) + default: + b.WriteString(text.String()) + } + start = end } return b.String() } @@ -130,7 +257,7 @@ func renderMarkdownTable(lines []string, width int) ([]string, int) { if i < len(row) { cell = row[i] } - naturalWidths[i] = max(naturalWidths[i], lipglossWidth(cell)) + naturalWidths[i] = max(naturalWidths[i], markdownInlineWidth(cell)) } } widths := markdownTableColumnWidths(naturalWidths, width) @@ -232,19 +359,21 @@ func sumInts(values []int) int { } func wrapMarkdownTableCell(cell string, width int) []string { - width = max(1, width) - var out []string - line := strings.TrimSpace(cell) - for lipglossWidth(line) > width { - cut := chatDisplayWidthCut(line, width) - out = append(out, strings.TrimSpace(line[:cut])) - line = strings.TrimSpace(line[cut:]) - } - out = append(out, line) - if len(out) == 0 { + lines := wrapInlineRunes(parseMarkdownInline(cell), max(1, width)) + if len(lines) == 0 { return []string{""} } - return out + return lines +} + +// markdownInlineWidth reports the visible width of a cell once Markdown +// delimiters are parsed away, so columns size to rendered content. +func markdownInlineWidth(cell string) int { + width := 0 + for _, item := range parseMarkdownInline(cell) { + width += runewidth.RuneWidth(item.r) + } + return width } func looksLikeMarkdownTableRow(line string) bool { diff --git a/cmd/tui/chat/render_test.go b/cmd/tui/chat/render_test.go index 86ff8650f..5b7ef72d4 100644 --- a/cmd/tui/chat/render_test.go +++ b/cmd/tui/chat/render_test.go @@ -738,6 +738,107 @@ func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) { } } +func TestChatStreamingRendersBoldBareURLAfterCompletion(t *testing.T) { + const response = "Draft PR opened: **https://github.com/ollama/ollama/pull/17203**" + m := chatModel{width: 80, height: 12, running: true, events: make(chan tea.Msg)} + + updated, _ := m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "Draft PR opened: **https://github.com/ollama/"}}) + m = updated.(chatModel) + if got := stripANSI(m.renderTranscript(80)); !strings.Contains(got, "**https://github.com/ollama/") { + t.Fatalf("incomplete Markdown should remain visible while streaming: %q", got) + } + + updated, _ = m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "ollama/pull/17203**"}}) + m = updated.(chatModel) + if got := m.entries[0].content; got != response { + t.Fatalf("streamed content = %q, want %q", got, response) + } + rendered := m.renderTranscript(80) + plain := stripANSI(rendered) + if strings.Contains(plain, "**") { + t.Fatalf("rendered response should not contain Markdown delimiters: %q", plain) + } + if !strings.Contains(plain, "Draft PR opened: https://github.com/ollama/ollama/pull/17203") { + t.Fatalf("rendered response missing URL: %q", plain) + } + if !strings.Contains(rendered, chatStrongStyle.Render("https://github.com/ollama/ollama/pull/17203")) { + t.Fatalf("URL should use the bold terminal style: %q", rendered) + } +} + +func TestRenderMarkdownInlineWrapsStrongTextWithoutDelimiters(t *testing.T) { + rendered := renderMarkdownForView("**alpha beta gamma delta epsilon**", 20) + plain := stripANSI(rendered) + if strings.Contains(plain, "**") { + t.Fatalf("wrapped strong text should not contain Markdown delimiters: %q", plain) + } + for _, line := range strings.Split(rendered, "\n") { + if got := lipgloss.Width(line); got > 20 { + t.Fatalf("rendered line width = %d, want <= 20: %q", got, line) + } + } +} + +func TestRenderMarkdownPreservesBareURLUnderscores(t *testing.T) { + const url = "https://example.com/a__b__" + if got := stripANSI(renderMarkdownForView(url, 80)); got != url { + t.Fatalf("bare URL = %q, want %q", got, url) + } +} + +func TestRenderMarkdownStrongAfterPunctuation(t *testing.T) { + for _, test := range []struct { + name string + input string + want string + emphasis string + }{ + { + name: "colon", + input: "Status: **ready**", + want: "Status: ready", + emphasis: "ready", + }, + { + name: "dash", + input: "Note-**important**", + want: "Note-important", + emphasis: "important", + }, + { + name: "closing parenthesis", + input: "Result) **complete**", + want: "Result) complete", + emphasis: "complete", + }, + { + name: "identifier", + input: "value__with_delimiters__", + want: "value__with_delimiters__", + }, + { + name: "URL", + input: "https://example.com/a__b__", + want: "https://example.com/a__b__", + }, + { + name: "URL punctuation", + input: "https://example.com/a-**b**", + want: "https://example.com/a-**b**", + }, + } { + t.Run(test.name, func(t *testing.T) { + rendered := renderMarkdownForView(test.input, 80) + if got := stripANSI(rendered); got != test.want { + t.Fatalf("rendered = %q, want %q", got, test.want) + } + if test.emphasis != "" && !strings.Contains(rendered, chatStrongStyle.Render(test.emphasis)) { + t.Fatalf("rendered output should emphasize %q: %q", test.emphasis, rendered) + } + }) + } +} + func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) { m := chatModel{ width: 80, @@ -1881,7 +1982,10 @@ func TestChatToolCallRendersPrettyInvocationAndResult(t *testing.T) { updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) view := stripANSI(m.renderTranscript(100)) - if !strings.Contains(view, "**Search results for:**") || !strings.Contains(view, "https://parthsareen.com") { + if strings.Contains(view, "**") { + t.Fatalf("inline web output should render Markdown, not show delimiters: %q", view) + } + if !strings.Contains(view, "Search results for:") || !strings.Contains(view, "https://parthsareen.com") { t.Fatalf("inline web output missing content: %q", view) } } diff --git a/cmd/tui/chat/theme.go b/cmd/tui/chat/theme.go index 5426c3f29..375f063d1 100644 --- a/cmd/tui/chat/theme.go +++ b/cmd/tui/chat/theme.go @@ -45,6 +45,9 @@ var ( chatInlineCodeStyle = lipgloss.NewStyle(). Bold(true) + chatStrongStyle = lipgloss.NewStyle(). + Bold(true) + chatCodeBlockStyle = lipgloss.NewStyle() chatTableBorderStyle = lipgloss.NewStyle().