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