v0.9.2 49bea57

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

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