Compare commits

...

1 Commits

Author SHA1 Message Date
ParthSareen 9b29453526 cmd/tui/chat: render bold emphasis consistently across markdown
Parse **...** / __...__ inline emphasis on the full source line before
wrapping, so bold stays intact across visual line breaks instead of
showing literal delimiters. Apply this path uniformly to assistant,
user, tool, heading, and table-cell output rather than only assistant
text.

- Add a parse-then-wrap inline renderer (wrapInlineRunes) shared by
  paragraphs, headings, and table cells
- Strip literal ** delimiters in headings and table cells; size table
  columns to rendered (delimiter-stripped) content
- Guard against misreading __ in bare URLs/identifiers as strong emphasis
- Add chatStrongStyle; update tests for the consistent bold behavior
2026-07-16 13:07:10 -07:00
3 changed files with 204 additions and 30 deletions
+149 -29
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,139 @@ 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
}
switch out[len(out)-1].r {
case '(', '[', '{', '"', '\'':
return true
default:
return unicode.IsSpace(out[len(out)-1].r)
}
}
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 +248,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 +350,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 {
+52 -1
View File
@@ -738,6 +738,54 @@ 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 TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
m := chatModel{
width: 80,
@@ -1872,7 +1920,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)
}
}
+3
View File
@@ -45,6 +45,9 @@ var (
chatInlineCodeStyle = lipgloss.NewStyle().
Bold(true)
chatStrongStyle = lipgloss.NewStyle().
Bold(true)
chatCodeBlockStyle = lipgloss.NewStyle()
chatTableBorderStyle = lipgloss.NewStyle().