v0.2.86 8fb72f3

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	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.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}

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 476

↑↓ moveenter openesc close