1package tui
2
3import (
4 "regexp"
5 "strings"
6)
7
8// FlushLeftSentinel was used previously to opt fenced code blocks
9// out of the prose indent. The current rendering keeps fences
10// aligned with surrounding prose, so the sentinel is no longer
11// emitted; the constant is kept (and exported) so any older
12// caller that still strips it remains a harmless no-op.
13const FlushLeftSentinel = '\x1c'
14
15// RenderMarkdown renders a small subset of Markdown to styled terminal
16// text using theme colors. Supported: headings, bold, italic, inline
17// code, fenced code blocks, bullet lists, numbered lists, blockquotes,
18// simple GitHub-style tables.
19// Not supported: links with complex formatting, HTML.
20//
21// width is used to draw horizontal rules (e.g. around code fences).
22// Pass 0 to use a reasonable fallback.
23func RenderMarkdown(src string, th Theme, width int) string {
24 if width <= 0 {
25 width = 80
26 }
27
28 lines := strings.Split(src, "\n")
29 var out strings.Builder
30 var fenceBuf strings.Builder
31 inFence := false
32 fenceLang := ""
33 fenceIndent := ""
34
35 // flushFence emits the buffered fence content without decorative
36 // horizontal rules. The tui draws rules around tool-result
37 // boxes, where they delimit real content; inside assistant
38 // prose they clutter the chat without adding information and
39 // look particularly bad around one-line snippets like `rm -rf
40 // foo`. Syntax highlighting alone is enough to signal "this is
41 // code"; unambiguous because prose doesn't use the accent
42 // palette.
43 flushFence := func() {
44 if fenceBuf.Len() == 0 {
45 return
46 }
47 code := strings.TrimRight(fenceBuf.String(), "\n")
48 if fenceLang != "" {
49 for _, l := range th.HighlightCode(code, fenceLang) {
50 out.WriteString(l)
51 out.WriteString("\n")
52 }
53 } else {
54 for _, l := range strings.Split(code, "\n") {
55 out.WriteString(th.FGColor(th.Accent, l))
56 out.WriteString("\n")
57 }
58 }
59 fenceBuf.Reset()
60 }
61
62 for idx := 0; idx < len(lines); idx++ {
63 line := lines[idx]
64 trim := strings.TrimLeft(line, " ")
65 if strings.HasPrefix(trim, "```") {
66 if inFence {
67 flushFence()
68 inFence = false
69 fenceLang = ""
70 } else {
71 inFence = true
72 fenceIndent = line[:len(line)-len(trim)]
73 fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
74 // Rule will be emitted by flushFence once the
75 // content is known so we can size it to the
76 // widest line inside the fence.
77 }
78 continue
79 }
80 if inFence {
81 line = strings.TrimPrefix(line, fenceIndent)
82 fenceBuf.WriteString(line)
83 fenceBuf.WriteString("\n")
84 continue
85 }
86 // GitHub-style table blocks. Detect a header row followed by
87 // a separator row like "| --- | ---: |" and consume all
88 // following pipe rows as table body. Done before inline
89 // rendering so cell widths can be measured and padded.
90 if idx+1 < len(lines) && looksLikeTableHeader(line, lines[idx+1]) {
91 block := []string{line, lines[idx+1]}
92 j := idx + 2
93 for j < len(lines) && looksLikeTableRow(lines[j]) {
94 block = append(block, lines[j])
95 j++
96 }
97 for _, rendered := range renderTable(block, th, width) {
98 out.WriteString(rendered)
99 out.WriteString("\n")
100 }
101 idx = j - 1
102 continue
103 }
104 // Headings.
105 if m := headingRE.FindStringSubmatch(line); m != nil {
106 level := len(m[1])
107 body := strings.TrimSpace(m[2])
108 prefix := strings.Repeat("#", level) + " "
109 out.WriteString(Bold(th.FGColor(th.Accent, prefix+body)) + "\n")
110 continue
111 }
112 // Blockquote.
113 if strings.HasPrefix(trim, "> ") {
114 body := strings.TrimPrefix(trim, "> ")
115 out.WriteString(th.FGColor(th.Muted, "┃ ") + renderInline(body, th) + "\n")
116 continue
117 }
118 // Bullet list.
119 if m := bulletRE.FindStringSubmatch(line); m != nil {
120 indent, body := m[1], m[2]
121 out.WriteString(indent + th.FGColor(th.Accent, "• ") + renderInline(body, th) + "\n")
122 continue
123 }
124 // Numbered list.
125 if m := numberRE.FindStringSubmatch(line); m != nil {
126 indent, num, body := m[1], m[2], m[3]
127 out.WriteString(indent + th.FGColor(th.Accent, num+". ") + renderInline(body, th) + "\n")
128 continue
129 }
130 out.WriteString(renderInline(line, th) + "\n")
131 }
132 // Handle streaming / truncated input: the opening ``` arrived
133 // but the closing one hasn't yet. Emit the buffered content
134 // with both rules so the partial fence still reads cleanly.
135 if inFence {
136 flushFence()
137 }
138 return strings.TrimRight(out.String(), "\n")
139}
140
141var (
142 headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
143 bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
144 numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
145 tableSepCell = regexp.MustCompile(`^:?-{3,}:?$`)
146
147 boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
148 italRE = regexp.MustCompile(`\*([^*]+)\*`)
149 codeRE = regexp.MustCompile("`([^`]+)`")
150)
151
152type tableAlign int
153
154const (
155 tableAlignLeft tableAlign = iota
156 tableAlignCenter
157 tableAlignRight
158)
159
160func looksLikeTableHeader(header, sep string) bool {
161 h := splitTableRow(header)
162 s, ok := parseTableSeparator(sep)
163 return ok && len(h) >= 2 && len(s) == len(h)
164}
165
166func looksLikeTableRow(line string) bool {
167 cells := splitTableRow(line)
168 return len(cells) >= 2
169}
170
171func splitTableRow(line string) []string {
172 line = strings.TrimSpace(line)
173 if !strings.Contains(line, "|") {
174 return nil
175 }
176 line = strings.TrimPrefix(line, "|")
177 line = strings.TrimSuffix(line, "|")
178 parts := splitUnescapedPipes(line)
179 for i := range parts {
180 parts[i] = strings.TrimSpace(strings.ReplaceAll(parts[i], `\|`, "|"))
181 }
182 return parts
183}
184
185func splitUnescapedPipes(s string) []string {
186 var parts []string
187 var b strings.Builder
188 escaped := false
189 for _, r := range s {
190 if escaped {
191 if r != '|' {
192 b.WriteRune('\\')
193 }
194 b.WriteRune(r)
195 escaped = false
196 continue
197 }
198 if r == '\\' {
199 escaped = true
200 continue
201 }
202 if r == '|' {
203 parts = append(parts, b.String())
204 b.Reset()
205 continue
206 }
207 b.WriteRune(r)
208 }
209 if escaped {
210 b.WriteRune('\\')
211 }
212 parts = append(parts, b.String())
213 return parts
214}
215
216func parseTableSeparator(line string) ([]tableAlign, bool) {
217 cells := splitTableRow(line)
218 if len(cells) < 2 {
219 return nil, false
220 }
221 aligns := make([]tableAlign, len(cells))
222 for i, cell := range cells {
223 compact := strings.ReplaceAll(strings.TrimSpace(cell), " ", "")
224 if !tableSepCell.MatchString(compact) {
225 return nil, false
226 }
227 left := strings.HasPrefix(compact, ":")
228 right := strings.HasSuffix(compact, ":")
229 switch {
230 case left && right:
231 aligns[i] = tableAlignCenter
232 case right:
233 aligns[i] = tableAlignRight
234 default:
235 aligns[i] = tableAlignLeft
236 }
237 }
238 return aligns, true
239}
240
241func renderTable(block []string, th Theme, maxWidth int) []string {
242 if len(block) < 2 {
243 return block
244 }
245 aligns, ok := parseTableSeparator(block[1])
246 if !ok {
247 return block
248 }
249 rows := make([][]string, 0, len(block)-1)
250 rows = append(rows, splitTableRow(block[0]))
251 for _, line := range block[2:] {
252 cells := splitTableRow(line)
253 if len(cells) == 0 {
254 continue
255 }
256 rows = append(rows, cells)
257 }
258 cols := len(aligns)
259 widths := make([]int, cols)
260 rendered := make([][]string, len(rows))
261 for r, row := range rows {
262 rendered[r] = make([]string, cols)
263 for c := 0; c < cols; c++ {
264 cell := ""
265 if c < len(row) {
266 cell = row[c]
267 }
268 styled := renderInline(cell, th)
269 rendered[r][c] = styled
270 if w := visibleWidth(styled); w > widths[c] {
271 widths[c] = w
272 }
273 }
274 }
275 for i := range widths {
276 if widths[i] < 3 {
277 widths[i] = 3
278 }
279 }
280 fitTableWidths(widths, maxWidth)
281
282 out := make([]string, 0, len(rows)+1)
283 out = append(out, renderTableRow(rendered[0], widths, aligns, th, true)...)
284 out = append(out, renderTableSeparator(widths, aligns, th))
285 for _, row := range rendered[1:] {
286 out = append(out, renderTableRow(row, widths, aligns, th, false)...)
287 }
288 return out
289}
290
291func fitTableWidths(widths []int, maxWidth int) {
292 cols := len(widths)
293 if cols == 0 || maxWidth <= 0 {
294 return
295 }
296 // Each column contributes one leading and one trailing space;
297 // there are cols+1 pipe separators. The rest is cell content.
298 avail := maxWidth - (cols + 1) - 2*cols
299 if avail < cols*3 {
300 avail = cols * 3
301 }
302 for sumInts(widths) > avail {
303 idx := widestColumn(widths)
304 if idx < 0 || widths[idx] <= 3 {
305 return
306 }
307 widths[idx]--
308 }
309}
310
311func sumInts(xs []int) int {
312 total := 0
313 for _, x := range xs {
314 total += x
315 }
316 return total
317}
318
319func widestColumn(widths []int) int {
320 idx := -1
321 best := 0
322 for i, w := range widths {
323 if w > best {
324 idx = i
325 best = w
326 }
327 }
328 return idx
329}
330
331func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, header bool) []string {
332 wrapped := make([][]string, len(widths))
333 height := 1
334 for c := range widths {
335 cell := ""
336 if c < len(row) {
337 cell = row[c]
338 }
339 parts := wrapANSILine(cell, widths[c])
340 if len(parts) == 0 {
341 parts = []string{""}
342 }
343 if header {
344 for i := range parts {
345 parts[i] = Bold(parts[i])
346 }
347 }
348 wrapped[c] = parts
349 if len(parts) > height {
350 height = len(parts)
351 }
352 }
353
354 out := make([]string, 0, height)
355 for r := 0; r < height; r++ {
356 var b strings.Builder
357 b.WriteString(th.FGColor(th.Muted, "|"))
358 for c := range widths {
359 cell := ""
360 if r < len(wrapped[c]) {
361 cell = wrapped[c][r]
362 }
363 b.WriteByte(' ')
364 b.WriteString(alignCell(cell, widths[c], aligns[c]))
365 b.WriteByte(' ')
366 b.WriteString(th.FGColor(th.Muted, "|"))
367 }
368 out = append(out, b.String())
369 }
370 return out
371}
372
373func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {
374 var b strings.Builder
375 b.WriteString(th.FGColor(th.Muted, "|"))
376 for c, w := range widths {
377 dashes := strings.Repeat("-", w)
378 switch aligns[c] {
379 case tableAlignCenter:
380 dashes = ":" + strings.Repeat("-", maxInt(1, w-2)) + ":"
381 case tableAlignRight:
382 dashes = strings.Repeat("-", maxInt(1, w-1)) + ":"
383 }
384 b.WriteByte(' ')
385 b.WriteString(th.FGColor(th.Muted, dashes))
386 b.WriteByte(' ')
387 b.WriteString(th.FGColor(th.Muted, "|"))
388 }
389 return b.String()
390}
391
392func alignCell(s string, width int, align tableAlign) string {
393 pad := width - visibleWidth(s)
394 if pad <= 0 {
395 return s
396 }
397 switch align {
398 case tableAlignRight:
399 return strings.Repeat(" ", pad) + s
400 case tableAlignCenter:
401 left := pad / 2
402 right := pad - left
403 return strings.Repeat(" ", left) + s + strings.Repeat(" ", right)
404 default:
405 return s + strings.Repeat(" ", pad)
406 }
407}
408
409func maxInt(a, b int) int {
410 if a > b {
411 return a
412 }
413 return b
414}
415
416func renderInline(s string, th Theme) string {
417 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
418 inner := m[1 : len(m)-1]
419 return th.FGColor(th.Accent, inner)
420 })
421 s = boldRE.ReplaceAllStringFunc(s, func(m string) string {
422 inner := m[2 : len(m)-2]
423 return Bold(inner)
424 })
425 s = italRE.ReplaceAllStringFunc(s, func(m string) string {
426 inner := m[1 : len(m)-1]
427 return Italic(inner)
428 })
429 return s
430}