1package tui
2
3import (
4 "regexp"
5 "strings"
6)
7
8// RenderMarkdown renders a small subset of Markdown to styled terminal
9// text using theme colors. Supported: headings, bold, italic, inline
10// code, fenced code blocks, bullet lists, numbered lists, blockquotes.
11// Not supported: tables, links with complex formatting, HTML.
12//
13// width is used to draw horizontal rules (e.g. around code fences).
14// Pass 0 to use a reasonable fallback.
15func RenderMarkdown(src string, th Theme, width int) string {
16 if width <= 0 {
17 width = 80
18 }
19
20 lines := strings.Split(src, "\n")
21 var out strings.Builder
22 var fenceBuf strings.Builder
23 inFence := false
24 fenceLang := ""
25 fenceIndent := ""
26
27 // flushFence emits the buffered fence content without decorative
28 // horizontal rules. The tui draws rules around tool-result
29 // boxes, where they delimit real content; inside assistant
30 // prose they clutter the chat without adding information and
31 // look particularly bad around one-line snippets like `rm -rf
32 // foo`. Syntax highlighting alone is enough to signal "this is
33 // code"; unambiguous because prose doesn't use the accent
34 // palette.
35 flushFence := func() {
36 if fenceBuf.Len() == 0 {
37 return
38 }
39 code := strings.TrimRight(fenceBuf.String(), "\n")
40 if fenceLang != "" {
41 for _, l := range HighlightCode(code, fenceLang) {
42 out.WriteString(l)
43 out.WriteString("\n")
44 }
45 } else {
46 for _, l := range strings.Split(code, "\n") {
47 out.WriteString(th.FG256(th.Accent, l))
48 out.WriteString("\n")
49 }
50 }
51 fenceBuf.Reset()
52 }
53
54 for _, line := range lines {
55 trim := strings.TrimLeft(line, " ")
56 if strings.HasPrefix(trim, "```") {
57 if inFence {
58 flushFence()
59 inFence = false
60 fenceLang = ""
61 } else {
62 inFence = true
63 fenceIndent = line[:len(line)-len(trim)]
64 fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
65 // Rule will be emitted by flushFence once the
66 // content is known so we can size it to the
67 // widest line inside the fence.
68 }
69 continue
70 }
71 if inFence {
72 if strings.HasPrefix(line, fenceIndent) {
73 line = line[len(fenceIndent):]
74 }
75 fenceBuf.WriteString(line)
76 fenceBuf.WriteString("\n")
77 continue
78 }
79 // Headings.
80 if m := headingRE.FindStringSubmatch(line); m != nil {
81 level := len(m[1])
82 body := strings.TrimSpace(m[2])
83 prefix := strings.Repeat("#", level) + " "
84 out.WriteString(Bold(th.FG256(th.Accent, prefix+body)) + "\n")
85 continue
86 }
87 // Blockquote.
88 if strings.HasPrefix(trim, "> ") {
89 body := strings.TrimPrefix(trim, "> ")
90 out.WriteString(th.FG256(th.Muted, "┃ ") + renderInline(body, th) + "\n")
91 continue
92 }
93 // Bullet list.
94 if m := bulletRE.FindStringSubmatch(line); m != nil {
95 indent, body := m[1], m[2]
96 out.WriteString(indent + th.FG256(th.Accent, "• ") + renderInline(body, th) + "\n")
97 continue
98 }
99 // Numbered list.
100 if m := numberRE.FindStringSubmatch(line); m != nil {
101 indent, num, body := m[1], m[2], m[3]
102 out.WriteString(indent + th.FG256(th.Accent, num+". ") + renderInline(body, th) + "\n")
103 continue
104 }
105 out.WriteString(renderInline(line, th) + "\n")
106 }
107 // Handle streaming / truncated input: the opening ``` arrived
108 // but the closing one hasn't yet. Emit the buffered content
109 // with both rules so the partial fence still reads cleanly.
110 if inFence {
111 flushFence()
112 }
113 return strings.TrimRight(out.String(), "\n")
114}
115
116var (
117 headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
118 bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
119 numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
120
121 boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
122 italRE = regexp.MustCompile(`\*([^*]+)\*`)
123 codeRE = regexp.MustCompile("`([^`]+)`")
124)
125
126func renderInline(s string, th Theme) string {
127 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
128 inner := m[1 : len(m)-1]
129 return th.FG256(th.Accent, inner)
130 })
131 s = boldRE.ReplaceAllStringFunc(s, func(m string) string {
132 inner := m[2 : len(m)-2]
133 return Bold(inner)
134 })
135 s = italRE.ReplaceAllStringFunc(s, func(m string) string {
136 inner := m[1 : len(m)-1]
137 return Italic(inner)
138 })
139 return s
140}