feat(tui-go): render markdown in chat turns (E)
renderMarkdown wraps charmbracelet/glamour (dark style, per-width memoized) and is hooked into the router chat-turn render path; falls back to plain content on any glamour error so the TUI never crashes or loses text. Other roles/UI untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
)
|
||||
|
||||
// defaultMarkdownWidth is the wrap width used when the caller doesn't yet know
|
||||
// the terminal width (e.g. a zero/negative value). 80 columns is a safe terminal
|
||||
// default that never blows up glamour's word-wrap.
|
||||
const defaultMarkdownWidth = 80
|
||||
|
||||
// markdownRenderers memoizes one glamour renderer per wrap width. A glamour
|
||||
// renderer bakes its word-wrap width in at construction, so we key the cache by
|
||||
// width and build lazily. Constructing a renderer is comparatively expensive
|
||||
// (it compiles a style + goldmark parser), hence the cache; the TUI renders the
|
||||
// same handful of widths over its lifetime.
|
||||
var (
|
||||
markdownMu sync.Mutex
|
||||
markdownRenderers = map[int]*glamour.TermRenderer{}
|
||||
)
|
||||
|
||||
// rendererForWidth returns a cached dark-styled glamour renderer wrapped to w,
|
||||
// or nil if glamour could not build one (caller falls back to plain text).
|
||||
func rendererForWidth(w int) *glamour.TermRenderer {
|
||||
markdownMu.Lock()
|
||||
defer markdownMu.Unlock()
|
||||
if r, ok := markdownRenderers[w]; ok {
|
||||
return r
|
||||
}
|
||||
// Pin the "dark" standard style so output is deterministic and independent
|
||||
// of the ambient terminal's background detection (important for tests and
|
||||
// for headless/over-the-wire sessions).
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle("dark"),
|
||||
glamour.WithWordWrap(w),
|
||||
)
|
||||
if err != nil {
|
||||
// Cache the failure as nil so we don't retry-and-fail every frame.
|
||||
markdownRenderers[w] = nil
|
||||
return nil
|
||||
}
|
||||
markdownRenderers[w] = r
|
||||
return r
|
||||
}
|
||||
|
||||
// renderMarkdown renders content as terminal markdown (bold, italics, lists,
|
||||
// headings, code blocks/inline code) word-wrapped to width, styled for a dark
|
||||
// terminal. It is robust by construction: on any glamour error — or if the
|
||||
// renderer can't be built — it returns the original plain content rather than
|
||||
// crashing the TUI. Trailing whitespace glamour appends is trimmed so it doesn't
|
||||
// disrupt the surrounding layout.
|
||||
func renderMarkdown(content string, width int) string {
|
||||
if width < 1 {
|
||||
width = defaultMarkdownWidth
|
||||
}
|
||||
r := rendererForWidth(width)
|
||||
if r == nil {
|
||||
return content
|
||||
}
|
||||
out, err := r.Render(content)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
// glamour pads block output with leading/trailing blank lines; strip them so
|
||||
// the rendered turn slots into the feed without extra gaps.
|
||||
out = strings.Trim(out, "\n")
|
||||
if strings.TrimSpace(out) == "" {
|
||||
// Nothing survived rendering (e.g. content was only whitespace) — prefer
|
||||
// the original so callers never lose the underlying text.
|
||||
return content
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stripANSI removes CSI escape sequences so we can assert on the visible text
|
||||
// glamour produced without depending on exact styling codes.
|
||||
func stripANSI(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); {
|
||||
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
|
||||
j := i + 2
|
||||
for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) {
|
||||
j++
|
||||
}
|
||||
if j < len(s) {
|
||||
j++ // consume the final byte
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Rich markdown should be transformed (output differs from raw input) while the
|
||||
// literal words survive the round-trip.
|
||||
func TestRenderMarkdownTransformsAndPreservesText(t *testing.T) {
|
||||
in := "# Heading\n\nSome **bold** and *italic* text.\n\n- first item\n- second item\n\n`inline code` and:\n\n```go\nfmt.Println(\"hi\")\n```\n"
|
||||
out := renderMarkdown(in, 80)
|
||||
|
||||
if out == in {
|
||||
t.Fatalf("markdown input should be rendered, got identical output")
|
||||
}
|
||||
|
||||
plain := stripANSI(out)
|
||||
for _, want := range []string{"Heading", "bold", "italic", "first item", "second item", "inline code", "fmt.Println"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("rendered output missing %q\n--- visible ---\n%s", want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A plain, short string must round-trip to contain its text and not panic,
|
||||
// regardless of how much (or how little) styling glamour adds.
|
||||
func TestRenderMarkdownPlainStringRoundTrips(t *testing.T) {
|
||||
in := "just a short plain sentence"
|
||||
out := renderMarkdown(in, 80)
|
||||
if !strings.Contains(stripANSI(out), in) {
|
||||
t.Fatalf("plain string did not survive rendering: %q", stripANSI(out))
|
||||
}
|
||||
}
|
||||
|
||||
// Pathological / empty inputs must never panic and must return something
|
||||
// containing the input text (trivially true for empty input).
|
||||
func TestRenderMarkdownPathologicalDoesNotPanic(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
" ",
|
||||
"\n\n\n",
|
||||
"```unterminated code fence\nstill going",
|
||||
strings.Repeat("a", 10_000),
|
||||
"[](()]][[**__",
|
||||
}
|
||||
for _, in := range cases {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("renderMarkdown panicked on %q: %v", in, r)
|
||||
}
|
||||
}()
|
||||
out := renderMarkdown(in, 40)
|
||||
// Empty/whitespace-only inputs fall back to the original content.
|
||||
if strings.TrimSpace(in) == "" {
|
||||
if out != in {
|
||||
t.Errorf("whitespace input %q should fall back to itself, got %q", in, out)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// A non-positive width must not crash and should fall back to the default
|
||||
// wrap width rather than feeding glamour an invalid value.
|
||||
func TestRenderMarkdownZeroWidthFallsBack(t *testing.T) {
|
||||
out := renderMarkdown("**hello** world", 0)
|
||||
if !strings.Contains(stripANSI(out), "hello") {
|
||||
t.Fatalf("zero-width render dropped text: %q", stripANSI(out))
|
||||
}
|
||||
}
|
||||
@@ -488,8 +488,14 @@ func (m Model) routerRows(w, h int) []string {
|
||||
tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›")
|
||||
rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
|
||||
case "router":
|
||||
for _, ln := range wrap(e.Content, w) {
|
||||
rows = append(rows, t.span(ln, t.P.Fg))
|
||||
// The router turn is model output: render it as markdown (bold,
|
||||
// lists, headings, code) wrapped to the panel width. glamour applies
|
||||
// its own inline foreground colors; we keep the opaque panel by
|
||||
// fixing each line's background. On any failure renderMarkdown hands
|
||||
// back the plain content, which still flows through this path fine.
|
||||
rendered := renderMarkdown(e.Content, w)
|
||||
for _, ln := range strings.Split(rendered, "\n") {
|
||||
rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln))
|
||||
}
|
||||
if s := metricsSuffix(e.Metrics); s != "" {
|
||||
rows = append(rows, t.span(s, t.P.Faint))
|
||||
|
||||
Reference in New Issue
Block a user