Files
2026-07-13 13:00:08 +08:00

519 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "")
}