detached a40d1cf

tui: render markdown tables

commit
a40d1cf56c65b487d7ba714e777fb927efa4b719
author
patriceckhart · · 2026-05-03 10:31 UTC
parent
266b1a5
signature
unsigned
refs
tag: v0.1.45
2 files · +380 -6
M internal/tui/markdown.go +295 -6
@@ -14,8 +14,9 @@ const FlushLeftSentinel = '\x1c'
1414
1515 // RenderMarkdown renders a small subset of Markdown to styled terminal
1616 // 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.
17+// code, fenced code blocks, bullet lists, numbered lists, blockquotes,
18+// simple GitHub-style tables.
19+// Not supported: links with complex formatting, HTML.
1920 //
2021 // width is used to draw horizontal rules (e.g. around code fences).
2122 // Pass 0 to use a reasonable fallback.
@@ -58,7 +59,8 @@ func RenderMarkdown(src string, th Theme, width int) string {
5859 fenceBuf.Reset()
5960 }
6061
61- for _, line := range lines {
62+ for idx := 0; idx < len(lines); idx++ {
63+ line := lines[idx]
6264 trim := strings.TrimLeft(line, " ")
6365 if strings.HasPrefix(trim, "```") {
6466 if inFence {
@@ -83,6 +85,24 @@ func RenderMarkdown(src string, th Theme, width int) string {
8385 fenceBuf.WriteString("\n")
8486 continue
8587 }
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+ }
86106 // Headings.
87107 if m := headingRE.FindStringSubmatch(line); m != nil {
88108 level := len(m[1])
@@ -121,15 +141,284 @@ func RenderMarkdown(src string, th Theme, width int) string {
121141 }
122142
123143 var (
124- headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
125- bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
126- numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
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,}:?$`)
127148
128149 boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
129150 italRE = regexp.MustCompile(`\*([^*]+)\*`)
130151 codeRE = regexp.MustCompile("`([^`]+)`")
131152 )
132153
154+type tableAlign int
155+
156+const (
157+ tableAlignLeft tableAlign = iota
158+ tableAlignCenter
159+ tableAlignRight
160+)
161+
162+func 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+
168+func looksLikeTableRow(line string) bool {
169+ cells := splitTableRow(line)
170+ return len(cells) >= 2
171+}
172+
173+func 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+
191+func 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+
222+func 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+
247+func 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+
297+func 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+
317+func sumInts(xs []int) int {
318+ total := 0
319+ for _, x := range xs {
320+ total += x
321+ }
322+ return total
323+}
324+
325+func 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+
337+func 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+
379+func 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+
398+func 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+
415+func maxInt(a, b int) int {
416+ if a > b {
417+ return a
418+ }
419+ return b
420+}
421+
133422 func renderInline(s string, th Theme) string {
134423 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
135424 inner := m[1 : len(m)-1]
@@ -14,8 +14,9 @@ const FlushLeftSentinel = '\x1c'
14 14
15 // RenderMarkdown renders a small subset of Markdown to styled terminal15 // RenderMarkdown renders a small subset of Markdown to styled terminal
16 // text using theme colors. Supported: headings, bold, italic, inline16 // text using theme colors. Supported: headings, bold, italic, inline
17-// code, fenced code blocks, bullet lists, numbered lists, blockquotes.17+// code, fenced code blocks, bullet lists, numbered lists, blockquotes,
18-// Not supported: tables, links with complex formatting, HTML.18+// simple GitHub-style tables.
19+// Not supported: links with complex formatting, HTML.
19 //20 //
20 // width is used to draw horizontal rules (e.g. around code fences).21 // width is used to draw horizontal rules (e.g. around code fences).
21 // Pass 0 to use a reasonable fallback.22 // Pass 0 to use a reasonable fallback.
@@ -58,7 +59,8 @@ func RenderMarkdown(src string, th Theme, width int) string {
58 fenceBuf.Reset()59 fenceBuf.Reset()
59 }60 }
60 61
61- for _, line := range lines {62+ for idx := 0; idx < len(lines); idx++ {
63+ line := lines[idx]
62 trim := strings.TrimLeft(line, " ")64 trim := strings.TrimLeft(line, " ")
63 if strings.HasPrefix(trim, "```") {65 if strings.HasPrefix(trim, "```") {
64 if inFence {66 if inFence {
@@ -83,6 +85,24 @@ func RenderMarkdown(src string, th Theme, width int) string {
83 fenceBuf.WriteString("\n")85 fenceBuf.WriteString("\n")
84 continue86 continue
85 }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+ }
86 // Headings.106 // Headings.
87 if m := headingRE.FindStringSubmatch(line); m != nil {107 if m := headingRE.FindStringSubmatch(line); m != nil {
88 level := len(m[1])108 level := len(m[1])
@@ -121,15 +141,284 @@ func RenderMarkdown(src string, th Theme, width int) string {
121 }141 }
122 142
123 var (143 var (
124- headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)144+ headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
125- bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)145+ bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
126- numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)146+ numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
147+ tableSepCell = regexp.MustCompile(`^:?-{3,}:?$`)
127 148
128 boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)149 boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
129 italRE = regexp.MustCompile(`\*([^*]+)\*`)150 italRE = regexp.MustCompile(`\*([^*]+)\*`)
130 codeRE = regexp.MustCompile("`([^`]+)`")151 codeRE = regexp.MustCompile("`([^`]+)`")
131 )152 )
132 153
154+type tableAlign int
155+
156+const (
157+ tableAlignLeft tableAlign = iota
158+ tableAlignCenter
159+ tableAlignRight
160+)
161+
162+func 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+
168+func looksLikeTableRow(line string) bool {
169+ cells := splitTableRow(line)
170+ return len(cells) >= 2
171+}
172+
173+func 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+
191+func 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+
222+func 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+
247+func 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+
297+func 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+
317+func sumInts(xs []int) int {
318+ total := 0
319+ for _, x := range xs {
320+ total += x
321+ }
322+ return total
323+}
324+
325+func 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+
337+func 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+
379+func 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+
398+func 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+
415+func maxInt(a, b int) int {
416+ if a > b {
417+ return a
418+ }
419+ return b
420+}
421+
133 func renderInline(s string, th Theme) string {422 func renderInline(s string, th Theme) string {
134 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {423 s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
135 inner := m[1 : len(m)-1]424 inner := m[1 : len(m)-1]
A internal/tui/markdown_test.go +85 -0
@@ -0,0 +1,85 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRenderMarkdownTableWrapsToWidth(t *testing.T) {
9+ in := strings.Join([]string{
10+ "| Area | What’s visible | Summary |",
11+ "| --- | --- | --- |",
12+ "| Overall UI | Dark terminal/TUI-style interface | A screenshot of an AI/coding-agent session, likely inside zot or a similar terminal app. |",
13+ "| Main content | Markdown-formatted response | The assistant is describing what was seen in a previous screenshot. |",
14+ }, "\n")
15+ out := RenderMarkdown(in, Dark, 80)
16+ plain := stripANSI(out)
17+ lines := strings.Split(plain, "\n")
18+ if len(lines) <= 4 {
19+ t.Fatalf("expected wrapped table rows, got:\n%s", plain)
20+ }
21+ for i, line := range lines {
22+ if visibleWidth(line) > 80 {
23+ t.Fatalf("line %d width %d > 80: %q\n%s", i, visibleWidth(line), line, plain)
24+ }
25+ }
26+ want := pipePositions(lines[0])
27+ for i, line := range lines[1:] {
28+ if got := pipePositions(line); !equalInts(got, want) {
29+ t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
30+ }
31+ }
32+}
33+
34+func TestRenderMarkdownTableAlignsColumns(t *testing.T) {
35+ in := strings.Join([]string{
36+ "| Tool | Core-Sprache | Runtime/Distribution |",
37+ "| --- | --- | --- |",
38+ "| Claude Code | TypeScript | Node.js |",
39+ "| Codex CLI | Rust | Natives Binary (npm-Wrapper) |",
40+ "| OpenCode | TypeScript (+ Go für TUI) | Bun, kompiliertes Binary |",
41+ "| zot | Go | Natives Binary |",
42+ }, "\n")
43+ out := RenderMarkdown(in, Dark, 80)
44+ plain := stripANSI(out)
45+ lines := strings.Split(plain, "\n")
46+ if len(lines) != 6 {
47+ t.Fatalf("got %d lines:\n%s", len(lines), plain)
48+ }
49+ counts := make([]int, len(lines))
50+ for i, line := range lines {
51+ counts[i] = strings.Count(line, "|")
52+ if counts[i] != 4 {
53+ t.Fatalf("line %d has %d pipes, want 4: %q\n%s", i, counts[i], line, plain)
54+ }
55+ }
56+ // Every pipe column should line up across all rows.
57+ want := pipePositions(lines[0])
58+ for i, line := range lines[1:] {
59+ if got := pipePositions(line); !equalInts(got, want) {
60+ t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
61+ }
62+ }
63+}
64+
65+func pipePositions(s string) []int {
66+ var out []int
67+ for i, r := range []rune(s) {
68+ if r == '|' {
69+ out = append(out, i)
70+ }
71+ }
72+ return out
73+}
74+
75+func equalInts(a, b []int) bool {
76+ if len(a) != len(b) {
77+ return false
78+ }
79+ for i := range a {
80+ if a[i] != b[i] {
81+ return false
82+ }
83+ }
84+ return true
85+}
@@ -0,0 +1,85 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestRenderMarkdownTableWrapsToWidth(t *testing.T) {
9+ in := strings.Join([]string{
10+ "| Area | What’s visible | Summary |",
11+ "| --- | --- | --- |",
12+ "| Overall UI | Dark terminal/TUI-style interface | A screenshot of an AI/coding-agent session, likely inside zot or a similar terminal app. |",
13+ "| Main content | Markdown-formatted response | The assistant is describing what was seen in a previous screenshot. |",
14+ }, "\n")
15+ out := RenderMarkdown(in, Dark, 80)
16+ plain := stripANSI(out)
17+ lines := strings.Split(plain, "\n")
18+ if len(lines) <= 4 {
19+ t.Fatalf("expected wrapped table rows, got:\n%s", plain)
20+ }
21+ for i, line := range lines {
22+ if visibleWidth(line) > 80 {
23+ t.Fatalf("line %d width %d > 80: %q\n%s", i, visibleWidth(line), line, plain)
24+ }
25+ }
26+ want := pipePositions(lines[0])
27+ for i, line := range lines[1:] {
28+ if got := pipePositions(line); !equalInts(got, want) {
29+ t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
30+ }
31+ }
32+}
33+
34+func TestRenderMarkdownTableAlignsColumns(t *testing.T) {
35+ in := strings.Join([]string{
36+ "| Tool | Core-Sprache | Runtime/Distribution |",
37+ "| --- | --- | --- |",
38+ "| Claude Code | TypeScript | Node.js |",
39+ "| Codex CLI | Rust | Natives Binary (npm-Wrapper) |",
40+ "| OpenCode | TypeScript (+ Go für TUI) | Bun, kompiliertes Binary |",
41+ "| zot | Go | Natives Binary |",
42+ }, "\n")
43+ out := RenderMarkdown(in, Dark, 80)
44+ plain := stripANSI(out)
45+ lines := strings.Split(plain, "\n")
46+ if len(lines) != 6 {
47+ t.Fatalf("got %d lines:\n%s", len(lines), plain)
48+ }
49+ counts := make([]int, len(lines))
50+ for i, line := range lines {
51+ counts[i] = strings.Count(line, "|")
52+ if counts[i] != 4 {
53+ t.Fatalf("line %d has %d pipes, want 4: %q\n%s", i, counts[i], line, plain)
54+ }
55+ }
56+ // Every pipe column should line up across all rows.
57+ want := pipePositions(lines[0])
58+ for i, line := range lines[1:] {
59+ if got := pipePositions(line); !equalInts(got, want) {
60+ t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
61+ }
62+ }
63+}
64+
65+func pipePositions(s string) []int {
66+ var out []int
67+ for i, r := range []rune(s) {
68+ if r == '|' {
69+ out = append(out, i)
70+ }
71+ }
72+ return out
73+}
74+
75+func equalInts(a, b []int) bool {
76+ if len(a) != len(b) {
77+ return false
78+ }
79+ for i := range a {
80+ if a[i] != b[i] {
81+ return false
82+ }
83+ }
84+ return true
85+}

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