v0.1.13 d53fbf4

markdown.go RawHistoryFolder

1package tui
2
3import (
4	"regexp"
5	"strings"
6)
7
8// RenderMarkdown renders a small subset of Markdown to styled terminal
9// text using theme colors. Supported: headings, bold, italic, inline
10// code, fenced code blocks, bullet lists, numbered lists, blockquotes.
11// Not supported: tables, links with complex formatting, HTML.
12//
13// width is used to draw horizontal rules (e.g. around code fences).
14// Pass 0 to use a reasonable fallback.
15func RenderMarkdown(src string, th Theme, width int) string {
16	if width <= 0 {
17		width = 80
18	}
19
20	lines := strings.Split(src, "\n")
21	var out strings.Builder
22	var fenceBuf strings.Builder
23	inFence := false
24	fenceLang := ""
25	fenceIndent := ""
26
27	// flushFence emits the buffered fence content without decorative
28	// horizontal rules. The tui draws rules around tool-result
29	// boxes, where they delimit real content; inside assistant
30	// prose they clutter the chat without adding information and
31	// look particularly bad around one-line snippets like `rm -rf
32	// foo`. Syntax highlighting alone is enough to signal "this is
33	// code"; unambiguous because prose doesn't use the accent
34	// palette.
35	flushFence := func() {
36		if fenceBuf.Len() == 0 {
37			return
38		}
39		code := strings.TrimRight(fenceBuf.String(), "\n")
40		if fenceLang != "" {
41			for _, l := range HighlightCode(code, fenceLang) {
42				out.WriteString(l)
43				out.WriteString("\n")
44			}
45		} else {
46			for _, l := range strings.Split(code, "\n") {
47				out.WriteString(th.FG256(th.Accent, l))
48				out.WriteString("\n")
49			}
50		}
51		fenceBuf.Reset()
52	}
53
54	for _, line := range lines {
55		trim := strings.TrimLeft(line, " ")
56		if strings.HasPrefix(trim, "```") {
57			if inFence {
58				flushFence()
59				inFence = false
60				fenceLang = ""
61			} else {
62				inFence = true
63				fenceIndent = line[:len(line)-len(trim)]
64				fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
65				// Rule will be emitted by flushFence once the
66				// content is known so we can size it to the
67				// widest line inside the fence.
68			}
69			continue
70		}
71		if inFence {
72			if strings.HasPrefix(line, fenceIndent) {
73				line = line[len(fenceIndent):]
74			}
75			fenceBuf.WriteString(line)
76			fenceBuf.WriteString("\n")
77			continue
78		}
79		// Headings.
80		if m := headingRE.FindStringSubmatch(line); m != nil {
81			level := len(m[1])
82			body := strings.TrimSpace(m[2])
83			prefix := strings.Repeat("#", level) + " "
84			out.WriteString(Bold(th.FG256(th.Accent, prefix+body)) + "\n")
85			continue
86		}
87		// Blockquote.
88		if strings.HasPrefix(trim, "> ") {
89			body := strings.TrimPrefix(trim, "> ")
90			out.WriteString(th.FG256(th.Muted, "┃ ") + renderInline(body, th) + "\n")
91			continue
92		}
93		// Bullet list.
94		if m := bulletRE.FindStringSubmatch(line); m != nil {
95			indent, body := m[1], m[2]
96			out.WriteString(indent + th.FG256(th.Accent, "• ") + renderInline(body, th) + "\n")
97			continue
98		}
99		// Numbered list.
100		if m := numberRE.FindStringSubmatch(line); m != nil {
101			indent, num, body := m[1], m[2], m[3]
102			out.WriteString(indent + th.FG256(th.Accent, num+". ") + renderInline(body, th) + "\n")
103			continue
104		}
105		out.WriteString(renderInline(line, th) + "\n")
106	}
107	// Handle streaming / truncated input: the opening ``` arrived
108	// but the closing one hasn't yet. Emit the buffered content
109	// with both rules so the partial fence still reads cleanly.
110	if inFence {
111		flushFence()
112	}
113	return strings.TrimRight(out.String(), "\n")
114}
115
116var (
117	headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
118	bulletRE  = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
119	numberRE  = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
120
121	boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
122	italRE = regexp.MustCompile(`\*([^*]+)\*`)
123	codeRE = regexp.MustCompile("`([^`]+)`")
124)
125
126func renderInline(s string, th Theme) string {
127	s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
128		inner := m[1 : len(m)-1]
129		return th.FG256(th.Accent, inner)
130	})
131	s = boldRE.ReplaceAllStringFunc(s, func(m string) string {
132		inner := m[2 : len(m)-2]
133		return Bold(inner)
134	})
135	s = italRE.ReplaceAllStringFunc(s, func(m string) string {
136		inner := m[1 : len(m)-1]
137		return Italic(inner)
138	})
139	return s
140}

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