da72ee5b80
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>
77 lines
2.6 KiB
Go
77 lines
2.6 KiB
Go
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
|
|
}
|