v0.44.0 efd47b3

markdown.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close