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