519 lines
15 KiB
Go
519 lines
15 KiB
Go
package cli
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"github.com/charmbracelet/x/ansi"
|
||
"github.com/yuin/goldmark"
|
||
"github.com/yuin/goldmark/ast"
|
||
"github.com/yuin/goldmark/extension"
|
||
extast "github.com/yuin/goldmark/extension/ast"
|
||
"github.com/yuin/goldmark/parser"
|
||
"github.com/yuin/goldmark/text"
|
||
"github.com/yuin/goldmark/util"
|
||
)
|
||
|
||
// mdRenderer turns the model's markdown answer into ANSI-styled terminal text
|
||
// using the brand palette. It implements only the constructs a chat-style
|
||
// model reliably emits — headings, paragraphs, lists, fenced code, blockquotes,
|
||
// strong/em/code-spans, links, thematic breaks — and degrades to plain text
|
||
// for anything else. Word-wrapping respects CJK widths and skips over ANSI
|
||
// SGR codes when counting columns.
|
||
type mdRenderer struct {
|
||
md goldmark.Markdown
|
||
width int
|
||
}
|
||
|
||
func newMarkdownRenderer(width int) *mdRenderer {
|
||
if width <= 0 {
|
||
width = 80
|
||
}
|
||
// Enable the GFM table extension so | header | rows | get parsed into
|
||
// a Table node rather than falling through as a literal text block.
|
||
return &mdRenderer{
|
||
md: goldmark.New(
|
||
goldmark.WithExtensions(extension.Table),
|
||
goldmark.WithParserOptions(
|
||
parser.WithInlineParsers(util.Prioritized(&mathParser{}, 150)),
|
||
),
|
||
),
|
||
width: width,
|
||
}
|
||
}
|
||
|
||
func italic(s string) string {
|
||
if !colorEnabled {
|
||
return s
|
||
}
|
||
return "\033[3m" + s + "\033[0m"
|
||
}
|
||
|
||
// Render parses input as markdown and returns ANSI-styled output with a
|
||
// trailing newline. Empty input returns an empty string so callers can
|
||
// reliably distinguish "nothing to draw" from "draw a blank line".
|
||
func (r *mdRenderer) Render(input string) string {
|
||
if strings.TrimSpace(input) == "" {
|
||
return ""
|
||
}
|
||
input = fixCJKEmphasis(normalizeMath(input))
|
||
src := []byte(input)
|
||
doc := r.md.Parser().Parse(text.NewReader(src))
|
||
var buf strings.Builder
|
||
r.renderBlocks(&buf, doc, src, 0)
|
||
out := strings.TrimRight(buf.String(), "\n")
|
||
if out == "" {
|
||
return ""
|
||
}
|
||
return out + "\n"
|
||
}
|
||
|
||
// fixCJKEmphasis works around goldmark's CommonMark parser not recognising
|
||
// CJK punctuation as Unicode punctuation: a closing ** is only right-flanking
|
||
// when the char before it is punctuation, so **X,**Y (, = U+FF0C) is not bold.
|
||
// Inserting a space after such a closer fixes the flanking. The space must go
|
||
// only on a *closer* — putting it after an opener (,**X** → ,** X**) would
|
||
// instead break the left-flanking — so emphasis open/close is tracked by a
|
||
// running toggle. Inline code spans and fenced blocks are passed through so
|
||
// literal ** inside code is never touched.
|
||
func fixCJKEmphasis(s string) string {
|
||
runes := []rune(s)
|
||
n := len(runes)
|
||
var b strings.Builder
|
||
b.Grow(len(s) + 16)
|
||
|
||
inFenced := false // inside ``` fenced code block
|
||
inCode := false // inside ` inline code span
|
||
inEmphasis := false // between an opening ** and its closer
|
||
|
||
for i := 0; i < n; i++ {
|
||
r := runes[i]
|
||
|
||
// Fenced code block: ``` toggles in/out.
|
||
if r == '`' && i+2 < n && runes[i+1] == '`' && runes[i+2] == '`' {
|
||
inFenced = !inFenced
|
||
b.WriteString("```")
|
||
i += 2
|
||
continue
|
||
}
|
||
// Inline code span: ` toggles in/out (but not inside fenced blocks).
|
||
if r == '`' && !inFenced {
|
||
inCode = !inCode
|
||
b.WriteRune(r)
|
||
continue
|
||
}
|
||
// Inside code — pass through verbatim.
|
||
if inCode || inFenced {
|
||
b.WriteRune(r)
|
||
continue
|
||
}
|
||
// Emphasis cannot span a hard line break; reset so an unclosed ** on a
|
||
// previous line can't make the next line's opener look like a closer.
|
||
if r == '\n' {
|
||
inEmphasis = false
|
||
b.WriteRune(r)
|
||
continue
|
||
}
|
||
|
||
if r == '*' && i+1 < n && runes[i+1] == '*' {
|
||
b.WriteString("**")
|
||
i++
|
||
inEmphasis = !inEmphasis
|
||
|
||
// Only a closer (emphasis just ended) hugging CJK punctuation needs
|
||
// the trailing space; the same space after an opener would break it.
|
||
if !inEmphasis && i >= 2 && !isSpace(runes[i-2]) && isCJKPunct(runes[i-2]) {
|
||
b.WriteByte(' ')
|
||
}
|
||
continue
|
||
}
|
||
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// isCJKPunct reports whether r is a CJK full-width punctuation character.
|
||
// These are not classified as Unicode punctuation by the CommonMark spec,
|
||
// which breaks the "right-flanking delimiter run" check for emphasis.
|
||
func isCJKPunct(r rune) bool {
|
||
if r <= 0x7F {
|
||
return false // ASCII punctuation is handled correctly by CommonMark
|
||
}
|
||
// Fast path: common CJK punctuation ranges.
|
||
switch {
|
||
case r >= 0x3000 && r <= 0x303F: // CJK Symbols and Punctuation (。、etc.)
|
||
return true
|
||
case r >= 0xFF01 && r <= 0xFF0F: // Fullwidth Forms I (! " # $ etc.)
|
||
return true
|
||
case r >= 0xFF1A && r <= 0xFF20: // Fullwidth Forms II (: ; < = etc.)
|
||
return true
|
||
case r >= 0xFF3B && r <= 0xFF3F: // Fullwidth Forms III ([ \ ] ^ _)
|
||
return true
|
||
case r >= 0xFF5B && r <= 0xFF65: // Fullwidth Forms IV ({ | } ~ etc.)
|
||
return true
|
||
}
|
||
// Fallback: any non-ASCII punctuation (e.g. Tibetan, Armenian).
|
||
return unicode.IsPunct(r)
|
||
}
|
||
|
||
// isSpace reports whether r is a whitespace character.
|
||
func isSpace(r rune) bool {
|
||
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||
}
|
||
|
||
func (r *mdRenderer) renderBlocks(buf *strings.Builder, parent ast.Node, src []byte, indent int) {
|
||
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
|
||
r.renderBlock(buf, c, src, indent)
|
||
}
|
||
}
|
||
|
||
func (r *mdRenderer) renderBlock(buf *strings.Builder, node ast.Node, src []byte, indent int) {
|
||
switch n := node.(type) {
|
||
case *ast.Heading:
|
||
r.renderHeading(buf, n, src, indent)
|
||
case *ast.Paragraph:
|
||
r.renderParagraph(buf, n, src, indent)
|
||
case *ast.TextBlock:
|
||
// TextBlock is goldmark's container for tight-list-item inline content
|
||
// (no trailing blank). Treat it like a paragraph but skip the spacer.
|
||
r.renderTextBlock(buf, n, src, indent)
|
||
case *ast.List:
|
||
r.renderList(buf, n, src, indent)
|
||
case *ast.FencedCodeBlock, *ast.CodeBlock:
|
||
r.renderFenced(buf, n, src, indent)
|
||
case *ast.Blockquote:
|
||
r.renderBlockquote(buf, n, src, indent)
|
||
case *extast.Table:
|
||
r.renderTable(buf, n, src, indent)
|
||
case *ast.ThematicBreak:
|
||
w := r.width - indent
|
||
if w < 8 {
|
||
w = 8
|
||
}
|
||
buf.WriteString(strings.Repeat(" ", indent))
|
||
buf.WriteString(dim(strings.Repeat("─", w)))
|
||
buf.WriteString("\n\n")
|
||
default:
|
||
// Unknown block: drop into children rather than dropping content.
|
||
r.renderBlocks(buf, node, src, indent)
|
||
}
|
||
}
|
||
|
||
func (r *mdRenderer) renderHeading(buf *strings.Builder, n *ast.Heading, src []byte, indent int) {
|
||
inline := r.collectInline(n, src)
|
||
buf.WriteString(strings.Repeat(" ", indent))
|
||
buf.WriteString(bold(accent(inline)))
|
||
buf.WriteString("\n")
|
||
// Level-1 headings get an accent underline; deeper levels rely on
|
||
// bold+colour alone so the hierarchy reads at a glance without piling
|
||
// on visual weight on every "###" in a long response.
|
||
if n.Level == 1 {
|
||
buf.WriteString(strings.Repeat(" ", indent))
|
||
buf.WriteString(accent(strings.Repeat("─", visibleWidth(inline))))
|
||
buf.WriteString("\n")
|
||
}
|
||
buf.WriteString("\n")
|
||
}
|
||
|
||
func (r *mdRenderer) renderParagraph(buf *strings.Builder, n *ast.Paragraph, src []byte, indent int) {
|
||
r.renderInlineBlock(buf, n, src, indent, true)
|
||
}
|
||
|
||
func (r *mdRenderer) renderTextBlock(buf *strings.Builder, n *ast.TextBlock, src []byte, indent int) {
|
||
r.renderInlineBlock(buf, n, src, indent, false)
|
||
}
|
||
|
||
func (r *mdRenderer) renderInlineBlock(buf *strings.Builder, n ast.Node, src []byte, indent int, trailingBlank bool) {
|
||
inline := r.collectInline(n, src)
|
||
prefix := strings.Repeat(" ", indent)
|
||
wrapped := wrapAnsi(inline, r.width-indent)
|
||
for _, line := range strings.Split(wrapped, "\n") {
|
||
buf.WriteString(prefix)
|
||
buf.WriteString(line)
|
||
buf.WriteString("\n")
|
||
}
|
||
if trailingBlank {
|
||
buf.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
func (r *mdRenderer) renderList(buf *strings.Builder, n *ast.List, src []byte, indent int) {
|
||
idx := 1
|
||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||
item, ok := c.(*ast.ListItem)
|
||
if !ok {
|
||
continue
|
||
}
|
||
var marker string
|
||
if n.IsOrdered() {
|
||
marker = fmt.Sprintf("%d.", idx)
|
||
idx++
|
||
} else {
|
||
marker = "•"
|
||
}
|
||
buf.WriteString(strings.Repeat(" ", indent))
|
||
buf.WriteString(accent(marker) + " ")
|
||
markerW := visibleWidth(marker) + 1
|
||
|
||
first := item.FirstChild()
|
||
// goldmark uses TextBlock for tight list items, Paragraph for loose
|
||
// ones; treat both as the marker-line carrier so the inline content
|
||
// lands next to the bullet either way.
|
||
inlineHost := inlineCarrier(first)
|
||
if inlineHost != nil {
|
||
inline := r.collectInline(inlineHost, src)
|
||
wrapped := wrapAnsi(inline, r.width-indent-markerW)
|
||
lines := strings.Split(wrapped, "\n")
|
||
buf.WriteString(lines[0] + "\n")
|
||
for _, l := range lines[1:] {
|
||
buf.WriteString(strings.Repeat(" ", indent+markerW))
|
||
buf.WriteString(l + "\n")
|
||
}
|
||
for s := first.NextSibling(); s != nil; s = s.NextSibling() {
|
||
r.renderBlock(buf, s, src, indent+markerW)
|
||
}
|
||
} else {
|
||
buf.WriteString("\n")
|
||
r.renderBlocks(buf, item, src, indent+2)
|
||
}
|
||
}
|
||
buf.WriteString("\n")
|
||
}
|
||
|
||
func (r *mdRenderer) renderFenced(buf *strings.Builder, n ast.Node, src []byte, indent int) {
|
||
prefix := strings.Repeat(" ", indent) + dim("│ ")
|
||
for i := 0; i < n.Lines().Len(); i++ {
|
||
l := n.Lines().At(i)
|
||
line := strings.TrimRight(string(l.Value(src)), "\n")
|
||
buf.WriteString(prefix)
|
||
buf.WriteString(accent(line))
|
||
buf.WriteString("\n")
|
||
}
|
||
buf.WriteString("\n")
|
||
}
|
||
|
||
func (r *mdRenderer) renderBlockquote(buf *strings.Builder, n *ast.Blockquote, src []byte, indent int) {
|
||
var inner strings.Builder
|
||
r.renderBlocks(&inner, n, src, 0)
|
||
prefix := strings.Repeat(" ", indent) + dim("▎ ")
|
||
for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") {
|
||
buf.WriteString(prefix)
|
||
buf.WriteString(dim(line))
|
||
buf.WriteString("\n")
|
||
}
|
||
buf.WriteString("\n")
|
||
}
|
||
|
||
// collectInline walks an inline subtree and returns its ANSI-styled flat text.
|
||
func (r *mdRenderer) collectInline(n ast.Node, src []byte) string {
|
||
var b strings.Builder
|
||
r.appendInline(&b, n, src)
|
||
return b.String()
|
||
}
|
||
|
||
func (r *mdRenderer) appendInline(b *strings.Builder, n ast.Node, src []byte) {
|
||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||
switch v := c.(type) {
|
||
case *ast.Text:
|
||
b.Write(v.Segment.Value(src))
|
||
switch {
|
||
case v.HardLineBreak():
|
||
b.WriteByte('\n')
|
||
case v.SoftLineBreak():
|
||
b.WriteByte(' ')
|
||
}
|
||
case *ast.Emphasis:
|
||
var inner strings.Builder
|
||
r.appendInline(&inner, v, src)
|
||
if v.Level == 2 {
|
||
b.WriteString(bold(inner.String()))
|
||
} else {
|
||
b.WriteString(italic(inner.String()))
|
||
}
|
||
case *ast.CodeSpan:
|
||
var inner strings.Builder
|
||
r.appendInline(&inner, v, src)
|
||
b.WriteString(accent(inner.String()))
|
||
case *ast.Link:
|
||
var inner strings.Builder
|
||
r.appendInline(&inner, v, src)
|
||
b.WriteString(inner.String())
|
||
b.WriteString(dim(" (" + string(v.Destination) + ")"))
|
||
case *ast.AutoLink:
|
||
b.WriteString(string(v.URL(src)))
|
||
case *ast.RawHTML:
|
||
// drop — rare in chat output and would print as literal escapes
|
||
case *mathNode:
|
||
b.WriteString(italic(v.value))
|
||
case *ast.String:
|
||
b.Write(v.Value)
|
||
default:
|
||
r.appendInline(b, c, src)
|
||
}
|
||
}
|
||
}
|
||
|
||
// renderTable lays out a GFM table as terminal columns separated by dim
|
||
// "│" rails with a "─┼─" rule under the header. Column widths auto-fit the
|
||
// widest cell in each column and are capped to a fair share of the terminal
|
||
// width so a wide table can't push the input off-screen. Long cells are
|
||
// wrapped across multiple visual rows (the whole logical row inflates to
|
||
// the tallest cell), not truncated, so no content is lost. Alignment is
|
||
// left-only — Markdown's ":---:" hints are read but not honoured yet.
|
||
func (r *mdRenderer) renderTable(buf *strings.Builder, n *extast.Table, src []byte, indent int) {
|
||
var header []string
|
||
var rows [][]string
|
||
|
||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||
switch row := c.(type) {
|
||
case *extast.TableHeader:
|
||
header = r.collectCells(row, src)
|
||
case *extast.TableRow:
|
||
rows = append(rows, r.collectCells(row, src))
|
||
}
|
||
}
|
||
if len(header) == 0 && len(rows) == 0 {
|
||
return
|
||
}
|
||
|
||
cols := len(header)
|
||
for _, row := range rows {
|
||
if len(row) > cols {
|
||
cols = len(row)
|
||
}
|
||
}
|
||
if cols == 0 {
|
||
return
|
||
}
|
||
|
||
// Initial widths fit the widest cell content per column.
|
||
widths := make([]int, cols)
|
||
pick := func(i, w int) {
|
||
if i < cols && w > widths[i] {
|
||
widths[i] = w
|
||
}
|
||
}
|
||
for i, h := range header {
|
||
pick(i, visibleWidth(h))
|
||
}
|
||
for _, row := range rows {
|
||
for i, c := range row {
|
||
pick(i, visibleWidth(c))
|
||
}
|
||
}
|
||
|
||
// Cap each column so the whole table fits the terminal: total = sum of
|
||
// widths + separators (3 chars each) + indent. Distribute the budget
|
||
// proportionally to the natural widths so columns with rich content
|
||
// keep more space than narrow ones.
|
||
available := r.width - indent - 3*(cols-1)
|
||
if available < cols*3 {
|
||
available = cols * 3
|
||
}
|
||
total := 0
|
||
for _, w := range widths {
|
||
total += w
|
||
}
|
||
if total > available {
|
||
for i := range widths {
|
||
widths[i] = widths[i] * available / total
|
||
if widths[i] < 3 {
|
||
widths[i] = 3
|
||
}
|
||
}
|
||
}
|
||
|
||
prefix := strings.Repeat(" ", indent)
|
||
sep := dim(" │ ")
|
||
|
||
if len(header) > 0 {
|
||
r.renderTableRow(buf, prefix, sep, header, widths, true)
|
||
buf.WriteString(prefix)
|
||
for i := range widths {
|
||
if i > 0 {
|
||
buf.WriteString(dim("─┼─"))
|
||
}
|
||
buf.WriteString(dim(strings.Repeat("─", widths[i])))
|
||
}
|
||
buf.WriteByte('\n')
|
||
}
|
||
for _, row := range rows {
|
||
r.renderTableRow(buf, prefix, sep, row, widths, false)
|
||
}
|
||
buf.WriteByte('\n')
|
||
}
|
||
|
||
// renderTableRow lays out one logical row across multiple visual rows when
|
||
// any cell wraps. wrapAnsi handles per-cell word + hard-break wrapping; the
|
||
// row's visual height = max wrapped lines across all cells. Cells that ran
|
||
// out of content get padded with spaces so the rail "│" stays aligned.
|
||
func (r *mdRenderer) renderTableRow(buf *strings.Builder, prefix, sep string, cells []string, widths []int, isHeader bool) {
|
||
cols := len(widths)
|
||
wrapped := make([][]string, cols)
|
||
maxLines := 1
|
||
for i := 0; i < cols; i++ {
|
||
var text string
|
||
if i < len(cells) {
|
||
text = cells[i]
|
||
}
|
||
wrapped[i] = strings.Split(wrapAnsi(text, widths[i]), "\n")
|
||
if len(wrapped[i]) > maxLines {
|
||
maxLines = len(wrapped[i])
|
||
}
|
||
}
|
||
for line := 0; line < maxLines; line++ {
|
||
buf.WriteString(prefix)
|
||
for i := 0; i < cols; i++ {
|
||
if i > 0 {
|
||
buf.WriteString(sep)
|
||
}
|
||
var cell string
|
||
if line < len(wrapped[i]) {
|
||
cell = wrapped[i][line]
|
||
}
|
||
padded := padRight(cell, widths[i])
|
||
if isHeader {
|
||
padded = bold(padded)
|
||
}
|
||
buf.WriteString(padded)
|
||
}
|
||
buf.WriteByte('\n')
|
||
}
|
||
}
|
||
|
||
// collectCells walks a TableHeader / TableRow node and pulls each TableCell's
|
||
// inline content as an ANSI-styled string. Non-cell children are ignored.
|
||
func (r *mdRenderer) collectCells(parent ast.Node, src []byte) []string {
|
||
var out []string
|
||
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
|
||
if cell, ok := c.(*extast.TableCell); ok {
|
||
out = append(out, strings.TrimSpace(r.collectInline(cell, src)))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// inlineCarrier returns n when it's a paragraph or text-block (both hold
|
||
// inline runs), else nil. Used by list rendering so the marker line gets the
|
||
// inline content regardless of whether the list is tight or loose.
|
||
func inlineCarrier(n ast.Node) ast.Node {
|
||
switch n.(type) {
|
||
case *ast.Paragraph, *ast.TextBlock:
|
||
return n
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// wrapAnsi word-wraps text to width columns, hard-breaking any single word too
|
||
// wide to fit on its own line — the path CJK takes, having no inter-word spaces.
|
||
// ANSI SGR escapes are preserved and counted as zero width; wide chars count as
|
||
// two columns. Thin wrapper over x/ansi's Wrap (already in the dep tree).
|
||
func wrapAnsi(text string, width int) string {
|
||
if width < 4 {
|
||
width = 4
|
||
}
|
||
return ansi.Wrap(text, width, "")
|
||
}
|