cmd/tui/chat: render bold emphasis consistently across markdown (#17224)

This commit is contained in:
Parth Sareen
2026-07-20 11:25:43 -07:00
committed by GitHub
parent 681dfaedcc
commit 0de38190d7
3 changed files with 266 additions and 30 deletions

View File

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

View File

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

View File

@@ -45,6 +45,9 @@ var (
chatInlineCodeStyle = lipgloss.NewStyle().
Bold(true)
chatStrongStyle = lipgloss.NewStyle().
Bold(true)
chatCodeBlockStyle = lipgloss.NewStyle()
chatTableBorderStyle = lipgloss.NewStyle().