v0.51.0 a9d674e

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

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