返回 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 copyMath bool
28 copyMathPrefix string
29 nextCopyMathID 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.copyMath = true
88 r.copyMathPrefix = prefix
89 r.nextCopyMathID = 0
90 r.renderBlocks(&buf, doc, src, 0)
91 r.copyMath = 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 := r.width - indent
219 if w < 8 {
220 w = 8
221 }
222 buf.WriteString(strings.Repeat(" ", indent))
223 buf.WriteString(dim(strings.Repeat("─", w)))
224 buf.WriteString("\n\n")
225 default:
226 // Unknown block: drop into children rather than dropping content.
227 r.renderBlocks(buf, node, src, indent)
228 }
229 }
230
231 func (r *mdRenderer) renderHeading(buf *strings.Builder, n *ast.Heading, src []byte, indent int) {
232 inline := r.collectInline(n, src)
233 buf.WriteString(strings.Repeat(" ", indent))
234 buf.WriteString(bold(accent(inline)))
235 buf.WriteString("\n")
236 // Level-1 headings get an accent underline; deeper levels rely on
237 // bold+colour alone so the hierarchy reads at a glance without piling
238 // on visual weight on every "###" in a long response.
239 if n.Level == 1 {
240 buf.WriteString(strings.Repeat(" ", indent))
241 buf.WriteString(accent(strings.Repeat("─", visibleWidth(inline))))
242 buf.WriteString("\n")
243 }
244 buf.WriteString("\n")
245 }
246
247 func (r *mdRenderer) renderParagraph(buf *strings.Builder, n *ast.Paragraph, src []byte, indent int) {
248 r.renderInlineBlock(buf, n, src, indent, true)
249 }
250
251 func (r *mdRenderer) renderTextBlock(buf *strings.Builder, n *ast.TextBlock, src []byte, indent int) {
252 r.renderInlineBlock(buf, n, src, indent, false)
253 }
254
255 func (r *mdRenderer) renderInlineBlock(buf *strings.Builder, n ast.Node, src []byte, indent int, trailingBlank bool) {
256 inline := r.collectInline(n, src)
257 prefix := strings.Repeat(" ", indent)
258 wrapped := wrapAnsi(inline, r.width-indent)
259 for _, line := range strings.Split(wrapped, "\n") {
260 buf.WriteString(prefix)
261 buf.WriteString(line)
262 buf.WriteString("\n")
263 }
264 if trailingBlank {
265 buf.WriteString("\n")
266 }
267 }
268
269 func (r *mdRenderer) renderList(buf *strings.Builder, n *ast.List, src []byte, indent int) {
270 idx := 1
271 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
272 item, ok := c.(*ast.ListItem)
273 if !ok {
274 continue
275 }
276 var marker string
277 if n.IsOrdered() {
278 marker = fmt.Sprintf("%d.", idx)
279 idx++
280 } else {
281 marker = "•"
282 }
283 buf.WriteString(strings.Repeat(" ", indent))
284 buf.WriteString(accent(marker) + " ")
285 markerW := visibleWidth(marker) + 1
286
287 first := item.FirstChild()
288 // goldmark uses TextBlock for tight list items, Paragraph for loose
289 // ones; treat both as the marker-line carrier so the inline content
290 // lands next to the bullet either way.
291 inlineHost := inlineCarrier(first)
292 if inlineHost != nil {
293 inline := r.collectInline(inlineHost, src)
294 wrapped := wrapAnsi(inline, r.width-indent-markerW)
295 lines := strings.Split(wrapped, "\n")
296 buf.WriteString(lines[0] + "\n")
297 for _, l := range lines[1:] {
298 buf.WriteString(strings.Repeat(" ", indent+markerW))
299 buf.WriteString(l + "\n")
300 }
301 for s := first.NextSibling(); s != nil; s = s.NextSibling() {
302 r.renderBlock(buf, s, src, indent+markerW)
303 }
304 } else {
305 buf.WriteString("\n")
306 r.renderBlocks(buf, item, src, indent+2)
307 }
308 }
309 buf.WriteString("\n")
310 }
311
312 func (r *mdRenderer) renderFenced(buf *strings.Builder, n ast.Node, src []byte, indent int) {
313 prefix := strings.Repeat(" ", indent) + dim("│ ")
314 for i := 0; i < n.Lines().Len(); i++ {
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 r.renderBlocks(&inner, n, src, 0)
327 prefix := strings.Repeat(" ", indent) + dim("▎ ")
328 for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") {
329 buf.WriteString(prefix)
330 buf.WriteString(dim(line))
331 buf.WriteString("\n")
332 }
333 buf.WriteString("\n")
334 }
335
336 // collectInline walks an inline subtree and returns its ANSI-styled flat text.
337 func (r *mdRenderer) collectInline(n ast.Node, src []byte) string {
338 var b strings.Builder
339 r.appendInline(&b, n, src)
340 return b.String()
341 }
342
343 func (r *mdRenderer) appendInline(b *strings.Builder, n ast.Node, src []byte) {
344 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
345 switch v := c.(type) {
346 case *ast.Text:
347 b.Write(v.Segment.Value(src))
348 switch {
349 case v.HardLineBreak():
350 b.WriteByte('\n')
351 case v.SoftLineBreak():
352 b.WriteByte(' ')
353 }
354 case *ast.Emphasis:
355 var inner strings.Builder
356 r.appendInline(&inner, v, src)
357 if v.Level == 2 {
358 b.WriteString(bold(inner.String()))
359 } else {
360 b.WriteString(italic(inner.String()))
361 }
362 case *ast.CodeSpan:
363 var inner strings.Builder
364 r.appendInline(&inner, v, src)
365 b.WriteString(accent(inner.String()))
366 case *ast.Link:
367 var inner strings.Builder
368 r.appendInline(&inner, v, src)
369 b.WriteString(inner.String())
370 b.WriteString(dim(" (" + string(v.Destination) + ")"))
371 case *ast.AutoLink:
372 b.WriteString(string(v.URL(src)))
373 case *ast.RawHTML:
374 // drop — rare in chat output and would print as literal escapes
375 case *mathNode:
376 rendered := italic(v.value)
377 if !r.copyMath {
378 b.WriteString(rendered)
379 break
380 }
381 source := "$" + v.source + "$"
382 if v.display {
383 source = "$$" + v.source + "$$"
384 }
385 id := fmt.Sprintf("%s-%d", r.copyMathPrefix, r.nextCopyMathID)
386 r.nextCopyMathID++
387 b.WriteString(copyMathStartMarker(id, source))
388 b.WriteString(rendered)
389 b.WriteString(copyMathEndMarker(id))
390 case *ast.String:
391 b.Write(v.Value)
392 default:
393 r.appendInline(b, c, src)
394 }
395 }
396 }
397
398 // renderTable lays out a GFM table as terminal columns separated by dim
399 // "│" rails with a "─┼─" rule under the header. Column widths auto-fit the
400 // widest cell in each column and are capped to a fair share of the terminal
401 // width so a wide table can't push the input off-screen. Long cells are
402 // wrapped across multiple visual rows (the whole logical row inflates to
403 // the tallest cell), not truncated, so no content is lost. Alignment is
404 // left-only — Markdown's ":---:" hints are read but not honoured yet.
405 func (r *mdRenderer) renderTable(buf *strings.Builder, n *extast.Table, src []byte, indent int) {
406 var header []string
407 var rows [][]string
408
409 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
410 switch row := c.(type) {
411 case *extast.TableHeader:
412 header = r.collectCells(row, src)
413 case *extast.TableRow:
414 rows = append(rows, r.collectCells(row, src))
415 }
416 }
417 if len(header) == 0 && len(rows) == 0 {
418 return
419 }
420
421 cols := len(header)
422 for _, row := range rows {
423 if len(row) > cols {
424 cols = len(row)
425 }
426 }
427 if cols == 0 {
428 return
429 }
430
431 // Initial widths fit the widest cell content per column.
432 widths := make([]int, cols)
433 pick := func(i, w int) {
434 if i < cols && w > widths[i] {
435 widths[i] = w
436 }
437 }
438 for i, h := range header {
439 pick(i, visibleWidth(h))
440 }
441 for _, row := range rows {
442 for i, c := range row {
443 pick(i, visibleWidth(c))
444 }
445 }
446
447 // Cap each column so the whole table fits the terminal: total = sum of
448 // widths + separators (3 chars each) + indent. Distribute the budget
449 // proportionally to the natural widths so columns with rich content
450 // keep more space than narrow ones.
451 available := r.width - indent - 3*(cols-1)
452 if available < cols*3 {
453 available = cols * 3
454 }
455 total := 0
456 for _, w := range widths {
457 total += w
458 }
459 if total > available {
460 for i := range widths {
461 widths[i] = widths[i] * available / total
462 if widths[i] < 3 {
463 widths[i] = 3
464 }
465 }
466 }
467
468 prefix := strings.Repeat(" ", indent)
469 sep := dim(" │ ")
470
471 if len(header) > 0 {
472 r.renderTableRow(buf, prefix, sep, header, widths, true)
473 buf.WriteString(prefix)
474 for i := range widths {
475 if i > 0 {
476 buf.WriteString(dim("─┼─"))
477 }
478 buf.WriteString(dim(strings.Repeat("─", widths[i])))
479 }
480 buf.WriteByte('\n')
481 }
482 for _, row := range rows {
483 r.renderTableRow(buf, prefix, sep, row, widths, false)
484 }
485 buf.WriteByte('\n')
486 }
487
488 // renderTableRow lays out one logical row across multiple visual rows when
489 // any cell wraps. wrapAnsi handles per-cell word + hard-break wrapping; the
490 // row's visual height = max wrapped lines across all cells. Cells that ran
491 // out of content get padded with spaces so the rail "│" stays aligned.
492 func (r *mdRenderer) renderTableRow(buf *strings.Builder, prefix, sep string, cells []string, widths []int, isHeader bool) {
493 cols := len(widths)
494 wrapped := make([][]string, cols)
495 maxLines := 1
496 for i := 0; i < cols; i++ {
497 var text string
498 if i < len(cells) {
499 text = cells[i]
500 }
501 wrapped[i] = strings.Split(wrapAnsi(text, widths[i]), "\n")
502 if len(wrapped[i]) > maxLines {
503 maxLines = len(wrapped[i])
504 }
505 }
506 for line := 0; line < maxLines; line++ {
507 buf.WriteString(prefix)
508 for i := 0; i < cols; i++ {
509 if i > 0 {
510 buf.WriteString(sep)
511 }
512 var cell string
513 if line < len(wrapped[i]) {
514 cell = wrapped[i][line]
515 }
516 padded := padRight(cell, widths[i])
517 if isHeader {
518 padded = bold(padded)
519 }
520 buf.WriteString(padded)
521 }
522 buf.WriteByte('\n')
523 }
524 }
525
526 // collectCells walks a TableHeader / TableRow node and pulls each TableCell's
527 // inline content as an ANSI-styled string. Non-cell children are ignored.
528 func (r *mdRenderer) collectCells(parent ast.Node, src []byte) []string {
529 var out []string
530 for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
531 if cell, ok := c.(*extast.TableCell); ok {
532 out = append(out, strings.TrimSpace(r.collectInline(cell, src)))
533 }
534 }
535 return out
536 }
537
538 // inlineCarrier returns n when it's a paragraph or text-block (both hold
539 // inline runs), else nil. Used by list rendering so the marker line gets the
540 // inline content regardless of whether the list is tight or loose.
541 func inlineCarrier(n ast.Node) ast.Node {
542 switch n.(type) {
543 case *ast.Paragraph, *ast.TextBlock:
544 return n
545 }
546 return nil
547 }
548
549 // wrapAnsi word-wraps text to width columns, hard-breaking any single word too
550 // wide to fit on its own line — the path CJK takes, having no inter-word spaces.
551 // ANSI SGR escapes are preserved and counted as zero width; wide chars count as
552 // two columns. Thin wrapper over x/ansi's Wrap (already in the dep tree).
553 func wrapAnsi(text string, width int) string {
554 if width < 4 {
555 width = 4
556 }
557 return ansi.Wrap(text, width, "")
558 }
559
559 lines GO