返回 DeepSeek-Reasonix
md.go
根目录 / internal / cli / md.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6 "unicode"
7
8 "github.com/charmbracelet/x/ansi"
9 "github.com/yuin/goldmark"
10 "github.com/yuin/goldmark/ast"
11 "github.com/yuin/goldmark/extension"
12 extast "github.com/yuin/goldmark/extension/ast"
13 "github.com/yuin/goldmark/parser"
14 "github.com/yuin/goldmark/text"
15 "github.com/yuin/goldmark/util"
16 )
17
18 // mdRenderer turns the model's markdown answer into ANSI-styled terminal text
19 // using the brand palette. It implements only the constructs a chat-style
20 // model reliably emits — headings, paragraphs, lists, fenced code, blockquotes,
21 // strong/em/code-spans, links, thematic breaks — and degrades to plain text
22 // for anything else. Word-wrapping respects CJK widths and skips over ANSI
23 // SGR codes when counting columns.
24 type mdRenderer struct {
25 md goldmark.Markdown
26 width int
27 copyMode bool
28 copySpanPrefix string
29 nextCopySpanID int
30 }
31
32 func newMarkdownRenderer(width int) *mdRenderer {
33 if width <= 0 {
34 width = 80
35 }
36 // Enable the GFM table extension so | header | rows | get parsed into
37 // a Table node rather than falling through as a literal text block.
38 return &mdRenderer{
39 md: goldmark.New(
40 goldmark.WithExtensions(extension.Table),
41 goldmark.WithParserOptions(
42 parser.WithInlineParsers(util.Prioritized(&mathParser{}, 150)),
43 ),
44 ),
45 width: width,
46 }
47 }
48
49 func italic(s string) string {
50 if !colorOn() {
51 return s
52 }
53 return "\033[3m" + s + "\033[0m"
54 }
55
56 // Render parses input as markdown and returns ANSI-styled output with a
57 // trailing newline. Empty input returns an empty string so callers can
58 // reliably distinguish "nothing to draw" from "draw a blank line".
59 func (r *mdRenderer) Render(input string) string {
60 if strings.TrimSpace(input) == "" {
61 return ""
62 }
63 input = fixCJKEmphasis(normalizeMath(input))
64 src := []byte(input)
65 doc := r.md.Parser().Parse(text.NewReader(src))
66 var buf strings.Builder
67 r.renderBlocks(&buf, doc, src, 0)
68 out := strings.TrimRight(buf.String(), "\n")
69 if out == "" {
70 return ""
71 }
72 return out + "\n"
73 }
74
75 // RenderCopy mirrors Render's visible output while surrounding math spans with
76 // zero-width internal markers. The markers are consumed only when the user
77 // copies a transcript selection, so display wrapping and selection coordinates
78 // stay identical without maintaining a second raw transcript.
79 func (r *mdRenderer) RenderCopy(input, prefix string) string {
80 if strings.TrimSpace(input) == "" {
81 return ""
82 }
83 input = fixCJKEmphasis(normalizeMath(input))
84 src := []byte(input)
85 doc := r.md.Parser().Parse(text.NewReader(src))
86 var buf strings.Builder
87 r.copyMode = true
88 r.copySpanPrefix = prefix
89 r.nextCopySpanID = 0
90 r.renderBlocks(&buf, doc, src, 0)
91 r.copyMode = false
92 out := strings.TrimRight(buf.String(), "\n")
93 if out == "" {
94 return ""
95 }
96 return out + "\n"
97 }
98
99 // fixCJKEmphasis works around goldmark's CommonMark parser not recognising
100 // CJK punctuation as Unicode punctuation: a closing ** is only right-flanking
101 // when the char before it is punctuation, so **X,**Y (, = U+FF0C) is not bold.
102 // Inserting a space after such a closer fixes the flanking. The space must go
103 // only on a *closer* — putting it after an opener (,**X** → ,** X**) would
104 // instead break the left-flanking — so emphasis open/close is tracked by a
105 // running toggle. Inline code spans and fenced blocks are passed through so
106 // literal ** inside code is never touched.
107 func fixCJKEmphasis(s string) string {
108 runes := []rune(s)
109 n := len(runes)
110 var b strings.Builder
111 b.Grow(len(s) + 16)
112
113 inFenced := false // inside ``` fenced code block
114 inCode := false // inside ` inline code span
115 inEmphasis := false // between an opening ** and its closer
116
117 for i := 0; i < n; i++ {
118 r := runes[i]
119
120 // Fenced code block: ``` toggles in/out.
121 if r == '`' && i+2 < n && runes[i+1] == '`' && runes[i+2] == '`' {
122 inFenced = !inFenced
123 b.WriteString("```")
124 i += 2
125 continue
126 }
127 // Inline code span: ` toggles in/out (but not inside fenced blocks).
128 if r == '`' && !inFenced {
129 inCode = !inCode
130 b.WriteRune(r)
131 continue
132 }
133 // Inside code — pass through verbatim.
134 if inCode || inFenced {
135 b.WriteRune(r)
136 continue
137 }
138 // Emphasis cannot span a hard line break; reset so an unclosed ** on a
139 // previous line can't make the next line's opener look like a closer.
140 if r == '\n' {
141 inEmphasis = false
142 b.WriteRune(r)
143 continue
144 }
145
146 if r == '*' && i+1 < n && runes[i+1] == '*' {
147 b.WriteString("**")
148 i++
149 inEmphasis = !inEmphasis
150
151 // Only a closer (emphasis just ended) hugging CJK punctuation needs
152 // the trailing space; the same space after an opener would break it.
153 if !inEmphasis && i >= 2 && !isSpace(runes[i-2]) && isCJKPunct(runes[i-2]) {
154 b.WriteByte(' ')
155 }
156 continue
157 }
158
159 b.WriteRune(r)
160 }
161 return b.String()
162 }
163
164 // isCJKPunct reports whether r is a CJK full-width punctuation character.
165 // These are not classified as Unicode punctuation by the CommonMark spec,
166 // which breaks the "right-flanking delimiter run" check for emphasis.
167 func isCJKPunct(r rune) bool {
168 if r <= 0x7F {
169 return false // ASCII punctuation is handled correctly by CommonMark
170 }
171 // Fast path: common CJK punctuation ranges.
172 switch {
173 case r >= 0x3000 && r <= 0x303F: // CJK Symbols and Punctuation (。、etc.)
174 return true
175 case r >= 0xFF01 && r <= 0xFF0F: // Fullwidth Forms I (! " # $ etc.)
176 return true
177 case r >= 0xFF1A && r <= 0xFF20: // Fullwidth Forms II (: ; < = etc.)
178 return true
179 case r >= 0xFF3B && r <= 0xFF3F: // Fullwidth Forms III ([ \ ] ^ _)
180 return true
181 case r >= 0xFF5B && r <= 0xFF65: // Fullwidth Forms IV ({ | } ~ etc.)
182 return true
183 }
184 // Fallback: any non-ASCII punctuation (e.g. Tibetan, Armenian).
185 return unicode.IsPunct(r)
186 }
187
188 // isSpace reports whether r is a whitespace character.
189 func isSpace(r rune) bool {
190 return r == ' ' || r == '\t' || r == '\n' || r == '\r'
191 }
192
193 func (r *mdRenderer) renderBlocks(buf *strings.Builder, parent ast.Node, src []byte, indent int) {
194 for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
195 r.renderBlock(buf, c, src, indent)
196 }
197 }
198
199 func (r *mdRenderer) renderBlock(buf *strings.Builder, node ast.Node, src []byte, indent int) {
200 switch n := node.(type) {
201 case *ast.Heading:
202 r.renderHeading(buf, n, src, indent)
203 case *ast.Paragraph:
204 r.renderParagraph(buf, n, src, indent)
205 case *ast.TextBlock:
206 // TextBlock is goldmark's container for tight-list-item inline content
207 // (no trailing blank). Treat it like a paragraph but skip the spacer.
208 r.renderTextBlock(buf, n, src, indent)
209 case *ast.List:
210 r.renderList(buf, n, src, indent)
211 case *ast.FencedCodeBlock, *ast.CodeBlock:
212 r.renderFenced(buf, n, src, indent)
213 case *ast.Blockquote:
214 r.renderBlockquote(buf, n, src, indent)
215 case *extast.Table:
216 r.renderTable(buf, n, src, indent)
217 case *ast.ThematicBreak:
218 w := max(r.width-indent, 8)
219 buf.WriteString(strings.Repeat(" ", indent))
220 buf.WriteString(dim(strings.Repeat("─", w)))
221 buf.WriteString("\n\n")
222 default:
223 // Unknown block: drop into children rather than dropping content.
224 r.renderBlocks(buf, node, src, indent)
225 }
226 }
227
228 func (r *mdRenderer) renderHeading(buf *strings.Builder, n *ast.Heading, src []byte, indent int) {
229 inline := r.collectInline(n, src)
230 buf.WriteString(strings.Repeat(" ", indent))
231 buf.WriteString(bold(accent(inline)))
232 buf.WriteString("\n")
233 // Level-1 headings get an accent underline; deeper levels rely on
234 // bold+colour alone so the hierarchy reads at a glance without piling
235 // on visual weight on every "###" in a long response.
236 if n.Level == 1 {
237 buf.WriteString(strings.Repeat(" ", indent))
238 buf.WriteString(accent(strings.Repeat("─", visibleWidth(inline))))
239 buf.WriteString("\n")
240 }
241 buf.WriteString("\n")
242 }
243
244 func (r *mdRenderer) renderParagraph(buf *strings.Builder, n *ast.Paragraph, src []byte, indent int) {
245 r.renderInlineBlock(buf, n, src, indent, true)
246 }
247
248 func (r *mdRenderer) renderTextBlock(buf *strings.Builder, n *ast.TextBlock, src []byte, indent int) {
249 r.renderInlineBlock(buf, n, src, indent, false)
250 }
251
252 func (r *mdRenderer) renderInlineBlock(buf *strings.Builder, n ast.Node, src []byte, indent int, trailingBlank bool) {
253 inline := r.collectInline(n, src)
254 prefix := strings.Repeat(" ", indent)
255 wrapped := wrapAnsi(inline, r.width-indent)
256 for line := range strings.SplitSeq(wrapped, "\n") {
257 buf.WriteString(prefix)
258 buf.WriteString(line)
259 buf.WriteString("\n")
260 }
261 if trailingBlank {
262 buf.WriteString("\n")
263 }
264 }
265
266 func (r *mdRenderer) renderList(buf *strings.Builder, n *ast.List, src []byte, indent int) {
267 idx := 1
268 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
269 item, ok := c.(*ast.ListItem)
270 if !ok {
271 continue
272 }
273 var marker string
274 if n.IsOrdered() {
275 marker = fmt.Sprintf("%d.", idx)
276 idx++
277 } else {
278 marker = "•"
279 }
280 buf.WriteString(strings.Repeat(" ", indent))
281 buf.WriteString(accent(marker) + " ")
282 markerW := visibleWidth(marker) + 1
283
284 first := item.FirstChild()
285 // goldmark uses TextBlock for tight list items, Paragraph for loose
286 // ones; treat both as the marker-line carrier so the inline content
287 // lands next to the bullet either way.
288 inlineHost := inlineCarrier(first)
289 if inlineHost != nil {
290 inline := r.collectInline(inlineHost, src)
291 wrapped := wrapAnsi(inline, r.width-indent-markerW)
292 lines := strings.Split(wrapped, "\n")
293 buf.WriteString(lines[0] + "\n")
294 for _, l := range lines[1:] {
295 buf.WriteString(strings.Repeat(" ", indent+markerW))
296 buf.WriteString(l + "\n")
297 }
298 for s := first.NextSibling(); s != nil; s = s.NextSibling() {
299 r.renderBlock(buf, s, src, indent+markerW)
300 }
301 } else {
302 buf.WriteString("\n")
303 r.renderBlocks(buf, item, src, indent+2)
304 }
305 }
306 buf.WriteString("\n")
307 }
308
309 func (r *mdRenderer) renderFenced(buf *strings.Builder, n ast.Node, src []byte, indent int) {
310 prefix := strings.Repeat(" ", indent) + dim("│ ")
311 if r.copyMode {
312 prefix = copyOmitSpan(prefix)
313 }
314 for i := range n.Lines().Len() {
315 l := n.Lines().At(i)
316 line := strings.TrimRight(string(l.Value(src)), "\n")
317 buf.WriteString(prefix)
318 buf.WriteString(accent(line))
319 buf.WriteString("\n")
320 }
321 buf.WriteString("\n")
322 }
323
324 func (r *mdRenderer) renderBlockquote(buf *strings.Builder, n *ast.Blockquote, src []byte, indent int) {
325 var inner strings.Builder
326 prefix := strings.Repeat(" ", indent) + dim("▎ ")
327 // The quote's body is laid out at column 0 and shifted right by the rail
328 // afterwards, so it has to be wrapped to what is left beside the rail.
329 outer := r.width
330 r.width = max(outer-visibleWidth(prefix), 8)
331 r.renderBlocks(&inner, n, src, 0)
332 r.width = outer
333 if r.copyMode {
334 prefix = copyOmitSpan(prefix)
335 }
336 for line := range strings.SplitSeq(strings.TrimRight(inner.String(), "\n"), "\n") {
337 buf.WriteString(prefix)
338 buf.WriteString(dim(line))
339 buf.WriteString("\n")
340 }
341 buf.WriteString("\n")
342 }
343
344 // collectInline walks an inline subtree and returns its ANSI-styled flat text.
345 func (r *mdRenderer) collectInline(n ast.Node, src []byte) string {
346 var b strings.Builder
347 r.appendInline(&b, n, src)
348 return b.String()
349 }
350
351 func (r *mdRenderer) appendInline(b *strings.Builder, n ast.Node, src []byte) {
352 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
353 switch v := c.(type) {
354 case *ast.Text:
355 b.Write(v.Segment.Value(src))
356 switch {
357 case v.HardLineBreak():
358 b.WriteByte('\n')
359 case v.SoftLineBreak():
360 b.WriteByte(' ')
361 }
362 case *ast.Emphasis:
363 var inner strings.Builder
364 r.appendInline(&inner, v, src)
365 if v.Level == 2 {
366 b.WriteString(bold(inner.String()))
367 } else {
368 b.WriteString(italic(inner.String()))
369 }
370 case *ast.CodeSpan:
371 var inner strings.Builder
372 r.appendInline(&inner, v, src)
373 b.WriteString(accent(inner.String()))
374 case *ast.Link:
375 var inner strings.Builder
376 r.appendInline(&inner, v, src)
377 b.WriteString(inner.String())
378 b.WriteString(dim(" (" + string(v.Destination) + ")"))
379 case *ast.AutoLink:
380 b.WriteString(string(v.URL(src)))
381 case *ast.RawHTML:
382 // drop — rare in chat output and would print as literal escapes
383 case *mathNode:
384 rendered := italic(v.value)
385 if !r.copyMode {
386 b.WriteString(rendered)
387 break
388 }
389 source := "$" + v.source + "$"
390 if v.display {
391 source = "$$" + v.source + "$$"
392 }
393 id := fmt.Sprintf("%s-%d", r.copySpanPrefix, r.nextCopySpanID)
394 r.nextCopySpanID++
395 b.WriteString(copySpanStartMarker(id, source))
396 b.WriteString(rendered)
397 b.WriteString(copySpanEndMarker(id))
398 case *ast.String:
399 b.Write(v.Value)
400 default:
401 r.appendInline(b, c, src)
402 }
403 }
404 }
405
406 // renderTable lays out a GFM table as terminal columns separated by dim
407 // "│" rails with a "─┼─" rule under the header. Column widths auto-fit the
408 // widest cell in each column and are capped to a fair share of the terminal
409 // width so a wide table can't push the input off-screen. Long cells are
410 // wrapped across multiple visual rows (the whole logical row inflates to
411 // the tallest cell), not truncated, so no content is lost. Alignment is
412 // left-only — Markdown's ":---:" hints are read but not honoured yet.
413 func (r *mdRenderer) renderTable(buf *strings.Builder, n *extast.Table, src []byte, indent int) {
414 var header []string
415 var rows [][]string
416
417 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
418 switch row := c.(type) {
419 case *extast.TableHeader:
420 header = r.collectCells(row, src)
421 case *extast.TableRow:
422 rows = append(rows, r.collectCells(row, src))
423 }
424 }
425 if len(header) == 0 && len(rows) == 0 {
426 return
427 }
428
429 cols := len(header)
430 for _, row := range rows {
431 if len(row) > cols {
432 cols = len(row)
433 }
434 }
435 if cols == 0 {
436 return
437 }
438
439 // Initial widths fit the widest cell content per column.
440 widths := make([]int, cols)
441 pick := func(i, w int) {
442 if i < cols && w > widths[i] {
443 widths[i] = w
444 }
445 }
446 for i, h := range header {
447 pick(i, visibleWidth(h))
448 }
449 for _, row := range rows {
450 for i, c := range row {
451 pick(i, visibleWidth(c))
452 }
453 }
454
455 // Cap each column so the whole table fits the terminal: total = sum of
456 // widths + separators (3 chars each) + indent. Distribute the budget
457 // proportionally to the natural widths so columns with rich content
458 // keep more space than narrow ones.
459 available := max(r.width-indent-3*(cols-1), cols*3)
460 total := 0
461 for _, w := range widths {
462 total += w
463 }
464 if total > available {
465 for i := range widths {
466 widths[i] = max(widths[i]*available/total, 3)
467 }
468 }
469
470 prefix := strings.Repeat(" ", indent)
471 sep := dim(" │ ")
472
473 if len(header) > 0 {
474 r.renderTableRow(buf, prefix, sep, header, widths, true)
475 buf.WriteString(prefix)
476 for i := range widths {
477 if i > 0 {
478 buf.WriteString(dim("─┼─"))
479 }
480 buf.WriteString(dim(strings.Repeat("─", widths[i])))
481 }
482 buf.WriteByte('\n')
483 }
484 for _, row := range rows {
485 r.renderTableRow(buf, prefix, sep, row, widths, false)
486 }
487 buf.WriteByte('\n')
488 }
489
490 // renderTableRow lays out one logical row across multiple visual rows when
491 // any cell wraps. wrapAnsi handles per-cell word + hard-break wrapping; the
492 // row's visual height = max wrapped lines across all cells. Cells that ran
493 // out of content get padded with spaces so the rail "│" stays aligned.
494 func (r *mdRenderer) renderTableRow(buf *strings.Builder, prefix, sep string, cells []string, widths []int, isHeader bool) {
495 cols := len(widths)
496 wrapped := make([][]string, cols)
497 maxLines := 1
498 for i := range cols {
499 var text string
500 if i < len(cells) {
501 text = cells[i]
502 }
503 wrapped[i] = strings.Split(wrapAnsi(text, widths[i]), "\n")
504 if len(wrapped[i]) > maxLines {
505 maxLines = len(wrapped[i])
506 }
507 }
508 for line := range maxLines {
509 buf.WriteString(prefix)
510 for i := range cols {
511 if i > 0 {
512 buf.WriteString(sep)
513 }
514 var cell string
515 if line < len(wrapped[i]) {
516 cell = wrapped[i][line]
517 }
518 padded := padRight(cell, widths[i])
519 if isHeader {
520 padded = bold(padded)
521 }
522 buf.WriteString(padded)
523 }
524 buf.WriteByte('\n')
525 }
526 }
527
528 // collectCells walks a TableHeader / TableRow node and pulls each TableCell's
529 // inline content as an ANSI-styled string. Non-cell children are ignored.
530 func (r *mdRenderer) collectCells(parent ast.Node, src []byte) []string {
531 var out []string
532 for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
533 if cell, ok := c.(*extast.TableCell); ok {
534 out = append(out, strings.TrimSpace(r.collectInline(cell, src)))
535 }
536 }
537 return out
538 }
539
540 // inlineCarrier returns n when it's a paragraph or text-block (both hold
541 // inline runs), else nil. Used by list rendering so the marker line gets the
542 // inline content regardless of whether the list is tight or loose.
543 func inlineCarrier(n ast.Node) ast.Node {
544 switch n.(type) {
545 case *ast.Paragraph, *ast.TextBlock:
546 return n
547 }
548 return nil
549 }
550
551 // wrapAnsi word-wraps text to width columns, hard-breaking any single word too
552 // wide to fit on its own line — the path CJK takes, having no inter-word spaces.
553 // ANSI SGR escapes are preserved and counted as zero width; wide chars count as
554 // two columns. Thin wrapper over x/ansi's Wrap (already in the dep tree).
555 func wrapAnsi(text string, width int) string {
556 if width < 4 {
557 width = 4
558 }
559 return ansi.Wrap(text, width, "")
560 }
561
561 lines GO