From a40d1cf56c65b487d7ba714e777fb927efa4b719 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Sun, 3 May 2026 12:31:11 +0200 Subject: [PATCH] tui: render markdown tables --- internal/tui/markdown.go | 301 +++++++++++++++++++++++++++++++++- internal/tui/markdown_test.go | 85 ++++++++++ 2 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 internal/tui/markdown_test.go diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go index 7471106..d840b14 100644 --- a/internal/tui/markdown.go +++ b/internal/tui/markdown.go @@ -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] diff --git a/internal/tui/markdown_test.go b/internal/tui/markdown_test.go new file mode 100644 index 0000000..d007f12 --- /dev/null +++ b/internal/tui/markdown_test.go @@ -0,0 +1,85 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderMarkdownTableWrapsToWidth(t *testing.T) { + in := strings.Join([]string{ + "| Area | What’s 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 +}