tui: render markdown tables

This commit is contained in:
patriceckhart 2026-05-03 12:31:11 +02:00
parent 266b1a52a4
commit a40d1cf56c
2 changed files with 380 additions and 6 deletions

View file

@ -14,8 +14,9 @@ const FlushLeftSentinel = '\x1c'
// RenderMarkdown renders a small subset of Markdown to styled terminal
// text using theme colors. Supported: headings, bold, italic, inline
// code, fenced code blocks, bullet lists, numbered lists, blockquotes.
// Not supported: tables, links with complex formatting, HTML.
// code, fenced code blocks, bullet lists, numbered lists, blockquotes,
// simple GitHub-style tables.
// Not supported: links with complex formatting, HTML.
//
// width is used to draw horizontal rules (e.g. around code fences).
// Pass 0 to use a reasonable fallback.
@ -58,7 +59,8 @@ func RenderMarkdown(src string, th Theme, width int) string {
fenceBuf.Reset()
}
for _, line := range lines {
for idx := 0; idx < len(lines); idx++ {
line := lines[idx]
trim := strings.TrimLeft(line, " ")
if strings.HasPrefix(trim, "```") {
if inFence {
@ -83,6 +85,24 @@ func RenderMarkdown(src string, th Theme, width int) string {
fenceBuf.WriteString("\n")
continue
}
// GitHub-style table blocks. Detect a header row followed by
// a separator row like "| --- | ---: |" and consume all
// following pipe rows as table body. Done before inline
// rendering so cell widths can be measured and padded.
if idx+1 < len(lines) && looksLikeTableHeader(line, lines[idx+1]) {
block := []string{line, lines[idx+1]}
j := idx + 2
for j < len(lines) && looksLikeTableRow(lines[j]) {
block = append(block, lines[j])
j++
}
for _, rendered := range renderTable(block, th, width) {
out.WriteString(rendered)
out.WriteString("\n")
}
idx = j - 1
continue
}
// Headings.
if m := headingRE.FindStringSubmatch(line); m != nil {
level := len(m[1])
@ -121,15 +141,284 @@ func RenderMarkdown(src string, th Theme, width int) string {
}
var (
headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`)
bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`)
numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`)
tableSepCell = regexp.MustCompile(`^:?-{3,}:?$`)
boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`)
italRE = regexp.MustCompile(`\*([^*]+)\*`)
codeRE = regexp.MustCompile("`([^`]+)`")
)
type tableAlign int
const (
tableAlignLeft tableAlign = iota
tableAlignCenter
tableAlignRight
)
func looksLikeTableHeader(header, sep string) bool {
h := splitTableRow(header)
s, ok := parseTableSeparator(sep)
return ok && len(h) >= 2 && len(s) == len(h)
}
func looksLikeTableRow(line string) bool {
cells := splitTableRow(line)
return len(cells) >= 2
}
func splitTableRow(line string) []string {
line = strings.TrimSpace(line)
if !strings.Contains(line, "|") {
return nil
}
if strings.HasPrefix(line, "|") {
line = strings.TrimPrefix(line, "|")
}
if strings.HasSuffix(line, "|") {
line = strings.TrimSuffix(line, "|")
}
parts := splitUnescapedPipes(line)
for i := range parts {
parts[i] = strings.TrimSpace(strings.ReplaceAll(parts[i], `\|`, "|"))
}
return parts
}
func splitUnescapedPipes(s string) []string {
var parts []string
var b strings.Builder
escaped := false
for _, r := range s {
if escaped {
if r != '|' {
b.WriteRune('\\')
}
b.WriteRune(r)
escaped = false
continue
}
if r == '\\' {
escaped = true
continue
}
if r == '|' {
parts = append(parts, b.String())
b.Reset()
continue
}
b.WriteRune(r)
}
if escaped {
b.WriteRune('\\')
}
parts = append(parts, b.String())
return parts
}
func parseTableSeparator(line string) ([]tableAlign, bool) {
cells := splitTableRow(line)
if len(cells) < 2 {
return nil, false
}
aligns := make([]tableAlign, len(cells))
for i, cell := range cells {
compact := strings.ReplaceAll(strings.TrimSpace(cell), " ", "")
if !tableSepCell.MatchString(compact) {
return nil, false
}
left := strings.HasPrefix(compact, ":")
right := strings.HasSuffix(compact, ":")
switch {
case left && right:
aligns[i] = tableAlignCenter
case right:
aligns[i] = tableAlignRight
default:
aligns[i] = tableAlignLeft
}
}
return aligns, true
}
func renderTable(block []string, th Theme, maxWidth int) []string {
if len(block) < 2 {
return block
}
aligns, ok := parseTableSeparator(block[1])
if !ok {
return block
}
rows := make([][]string, 0, len(block)-1)
rows = append(rows, splitTableRow(block[0]))
for _, line := range block[2:] {
cells := splitTableRow(line)
if len(cells) == 0 {
continue
}
rows = append(rows, cells)
}
cols := len(aligns)
widths := make([]int, cols)
rendered := make([][]string, len(rows))
for r, row := range rows {
rendered[r] = make([]string, cols)
for c := 0; c < cols; c++ {
cell := ""
if c < len(row) {
cell = row[c]
}
styled := renderInline(cell, th)
rendered[r][c] = styled
if w := visibleWidth(styled); w > widths[c] {
widths[c] = w
}
}
}
for i := range widths {
if widths[i] < 3 {
widths[i] = 3
}
}
fitTableWidths(widths, maxWidth)
out := make([]string, 0, len(rows)+1)
out = append(out, renderTableRow(rendered[0], widths, aligns, th, true)...)
out = append(out, renderTableSeparator(widths, aligns, th))
for _, row := range rendered[1:] {
out = append(out, renderTableRow(row, widths, aligns, th, false)...)
}
return out
}
func fitTableWidths(widths []int, maxWidth int) {
cols := len(widths)
if cols == 0 || maxWidth <= 0 {
return
}
// Each column contributes one leading and one trailing space;
// there are cols+1 pipe separators. The rest is cell content.
avail := maxWidth - (cols + 1) - 2*cols
if avail < cols*3 {
avail = cols * 3
}
for sumInts(widths) > avail {
idx := widestColumn(widths)
if idx < 0 || widths[idx] <= 3 {
return
}
widths[idx]--
}
}
func sumInts(xs []int) int {
total := 0
for _, x := range xs {
total += x
}
return total
}
func widestColumn(widths []int) int {
idx := -1
best := 0
for i, w := range widths {
if w > best {
idx = i
best = w
}
}
return idx
}
func renderTableRow(row []string, widths []int, aligns []tableAlign, th Theme, header bool) []string {
wrapped := make([][]string, len(widths))
height := 1
for c := range widths {
cell := ""
if c < len(row) {
cell = row[c]
}
parts := wrapANSILine(cell, widths[c])
if len(parts) == 0 {
parts = []string{""}
}
if header {
for i := range parts {
parts[i] = Bold(parts[i])
}
}
wrapped[c] = parts
if len(parts) > height {
height = len(parts)
}
}
out := make([]string, 0, height)
for r := 0; r < height; r++ {
var b strings.Builder
b.WriteString(th.FG256(th.Muted, "|"))
for c := range widths {
cell := ""
if r < len(wrapped[c]) {
cell = wrapped[c][r]
}
b.WriteByte(' ')
b.WriteString(alignCell(cell, widths[c], aligns[c]))
b.WriteByte(' ')
b.WriteString(th.FG256(th.Muted, "|"))
}
out = append(out, b.String())
}
return out
}
func renderTableSeparator(widths []int, aligns []tableAlign, th Theme) string {
var b strings.Builder
b.WriteString(th.FG256(th.Muted, "|"))
for c, w := range widths {
dashes := strings.Repeat("-", w)
switch aligns[c] {
case tableAlignCenter:
dashes = ":" + strings.Repeat("-", maxInt(1, w-2)) + ":"
case tableAlignRight:
dashes = strings.Repeat("-", maxInt(1, w-1)) + ":"
}
b.WriteByte(' ')
b.WriteString(th.FG256(th.Muted, dashes))
b.WriteByte(' ')
b.WriteString(th.FG256(th.Muted, "|"))
}
return b.String()
}
func alignCell(s string, width int, align tableAlign) string {
pad := width - visibleWidth(s)
if pad <= 0 {
return s
}
switch align {
case tableAlignRight:
return strings.Repeat(" ", pad) + s
case tableAlignCenter:
left := pad / 2
right := pad - left
return strings.Repeat(" ", left) + s + strings.Repeat(" ", right)
default:
return s + strings.Repeat(" ", pad)
}
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func renderInline(s string, th Theme) string {
s = codeRE.ReplaceAllStringFunc(s, func(m string) string {
inner := m[1 : len(m)-1]

View file

@ -0,0 +1,85 @@
package tui
import (
"strings"
"testing"
)
func TestRenderMarkdownTableWrapsToWidth(t *testing.T) {
in := strings.Join([]string{
"| Area | Whats visible | Summary |",
"| --- | --- | --- |",
"| Overall UI | Dark terminal/TUI-style interface | A screenshot of an AI/coding-agent session, likely inside zot or a similar terminal app. |",
"| Main content | Markdown-formatted response | The assistant is describing what was seen in a previous screenshot. |",
}, "\n")
out := RenderMarkdown(in, Dark, 80)
plain := stripANSI(out)
lines := strings.Split(plain, "\n")
if len(lines) <= 4 {
t.Fatalf("expected wrapped table rows, got:\n%s", plain)
}
for i, line := range lines {
if visibleWidth(line) > 80 {
t.Fatalf("line %d width %d > 80: %q\n%s", i, visibleWidth(line), line, plain)
}
}
want := pipePositions(lines[0])
for i, line := range lines[1:] {
if got := pipePositions(line); !equalInts(got, want) {
t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
}
}
}
func TestRenderMarkdownTableAlignsColumns(t *testing.T) {
in := strings.Join([]string{
"| Tool | Core-Sprache | Runtime/Distribution |",
"| --- | --- | --- |",
"| Claude Code | TypeScript | Node.js |",
"| Codex CLI | Rust | Natives Binary (npm-Wrapper) |",
"| OpenCode | TypeScript (+ Go für TUI) | Bun, kompiliertes Binary |",
"| zot | Go | Natives Binary |",
}, "\n")
out := RenderMarkdown(in, Dark, 80)
plain := stripANSI(out)
lines := strings.Split(plain, "\n")
if len(lines) != 6 {
t.Fatalf("got %d lines:\n%s", len(lines), plain)
}
counts := make([]int, len(lines))
for i, line := range lines {
counts[i] = strings.Count(line, "|")
if counts[i] != 4 {
t.Fatalf("line %d has %d pipes, want 4: %q\n%s", i, counts[i], line, plain)
}
}
// Every pipe column should line up across all rows.
want := pipePositions(lines[0])
for i, line := range lines[1:] {
if got := pipePositions(line); !equalInts(got, want) {
t.Fatalf("line %d pipe columns %v, want %v: %q\n%s", i+1, got, want, line, plain)
}
}
}
func pipePositions(s string) []int {
var out []int
for i, r := range []rune(s) {
if r == '|' {
out = append(out, i)
}
}
return out
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}