v0.1.43 527daf7

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

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