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