返回 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 r.renderBlocks(&inner, n, src, 0)
327 prefix := strings.Repeat(" ", indent) + dim("▎ ")
328 if r.copyMode {
329 prefix = copyOmitSpan(prefix)
330 }
331 for line := range strings.SplitSeq(strings.TrimRight(inner.String(), "\n"), "\n") {
332 buf.WriteString(prefix)
333 buf.WriteString(dim(line))
334 buf.WriteString("\n")
335 }
336 buf.WriteString("\n")
337 }
338
339 // collectInline walks an inline subtree and returns its ANSI-styled flat text.
340 func (r *mdRenderer) collectInline(n ast.Node, src []byte) string {
341 var b strings.Builder
342 r.appendInline(&b, n, src)
343 return b.String()
344 }
345
346 func (r *mdRenderer) appendInline(b *strings.Builder, n ast.Node, src []byte) {
347 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
348 switch v := c.(type) {
349 case *ast.Text:
350 b.Write(v.Segment.Value(src))
351 switch {
352 case v.HardLineBreak():
353 b.WriteByte('\n')
354 case v.SoftLineBreak():
355 b.WriteByte(' ')
356 }
357 case *ast.Emphasis:
358 var inner strings.Builder
359 r.appendInline(&inner, v, src)
360 if v.Level == 2 {
361 b.WriteString(bold(inner.String()))
362 } else {
363 b.WriteString(italic(inner.String()))
364 }
365 case *ast.CodeSpan:
366 var inner strings.Builder
367 r.appendInline(&inner, v, src)
368 b.WriteString(accent(inner.String()))
369 case *ast.Link:
370 var inner strings.Builder
371 r.appendInline(&inner, v, src)
372 b.WriteString(inner.String())
373 b.WriteString(dim(" (" + string(v.Destination) + ")"))
374 case *ast.AutoLink:
375 b.WriteString(string(v.URL(src)))
376 case *ast.RawHTML:
377 // drop — rare in chat output and would print as literal escapes
378 case *mathNode:
379 rendered := italic(v.value)
380 if !r.copyMode {
381 b.WriteString(rendered)
382 break
383 }
384 source := "$" + v.source + "$"
385 if v.display {
386 source = "$$" + v.source + "$$"
387 }
388 id := fmt.Sprintf("%s-%d", r.copySpanPrefix, r.nextCopySpanID)
389 r.nextCopySpanID++
390 b.WriteString(copySpanStartMarker(id, source))
391 b.WriteString(rendered)
392 b.WriteString(copySpanEndMarker(id))
393 case *ast.String:
394 b.Write(v.Value)
395 default:
396 r.appendInline(b, c, src)
397 }
398 }
399 }
400
401 // renderTable lays out a GFM table as terminal columns separated by dim
402 // "│" rails with a "─┼─" rule under the header. Column widths auto-fit the
403 // widest cell in each column and are capped to a fair share of the terminal
404 // width so a wide table can't push the input off-screen. Long cells are
405 // wrapped across multiple visual rows (the whole logical row inflates to
406 // the tallest cell), not truncated, so no content is lost. Alignment is
407 // left-only — Markdown's ":---:" hints are read but not honoured yet.
408 func (r *mdRenderer) renderTable(buf *strings.Builder, n *extast.Table, src []byte, indent int) {
409 var header []string
410 var rows [][]string
411
412 for c := n.FirstChild(); c != nil; c = c.NextSibling() {
413 switch row := c.(type) {
414 case *extast.TableHeader:
415 header = r.collectCells(row, src)
416 case *extast.TableRow:
417 rows = append(rows, r.collectCells(row, src))
418 }
419 }
420 if len(header) == 0 && len(rows) == 0 {
421 return
422 }
423
424 cols := len(header)
425 for _, row := range rows {
426 if len(row) > cols {
427 cols = len(row)
428 }
429 }
430 if cols == 0 {
431 return
432 }
433
434 // Initial widths fit the widest cell content per column.
435 widths := make([]int, cols)
436 pick := func(i, w int) {
437 if i < cols && w > widths[i] {
438 widths[i] = w
439 }
440 }
441 for i, h := range header {
442 pick(i, visibleWidth(h))
443 }
444 for _, row := range rows {
445 for i, c := range row {
446 pick(i, visibleWidth(c))
447 }
448 }
449
450 // Cap each column so the whole table fits the terminal: total = sum of
451 // widths + separators (3 chars each) + indent. Distribute the budget
452 // proportionally to the natural widths so columns with rich content
453 // keep more space than narrow ones.
454 available := max(r.width-indent-3*(cols-1), cols*3)
455 total := 0
456 for _, w := range widths {
457 total += w
458 }
459 if total > available {
460 for i := range widths {
461 widths[i] = max(widths[i]*available/total, 3)
462 }
463 }
464
465 prefix := strings.Repeat(" ", indent)
466 sep := dim(" │ ")
467
468 if len(header) > 0 {
469 r.renderTableRow(buf, prefix, sep, header, widths, true)
470 buf.WriteString(prefix)
471 for i := range widths {
472 if i > 0 {
473 buf.WriteString(dim("─┼─"))
474 }
475 buf.WriteString(dim(strings.Repeat("─", widths[i])))
476 }
477 buf.WriteByte('\n')
478 }
479 for _, row := range rows {
480 r.renderTableRow(buf, prefix, sep, row, widths, false)
481 }
482 buf.WriteByte('\n')
483 }
484
485 // renderTableRow lays out one logical row across multiple visual rows when
486 // any cell wraps. wrapAnsi handles per-cell word + hard-break wrapping; the
487 // row's visual height = max wrapped lines across all cells. Cells that ran
488 // out of content get padded with spaces so the rail "│" stays aligned.
489 func (r *mdRenderer) renderTableRow(buf *strings.Builder, prefix, sep string, cells []string, widths []int, isHeader bool) {
490 cols := len(widths)
491 wrapped := make([][]string, cols)
492 maxLines := 1
493 for i := range cols {
494 var text string
495 if i < len(cells) {
496 text = cells[i]
497 }
498 wrapped[i] = strings.Split(wrapAnsi(text, widths[i]), "\n")
499 if len(wrapped[i]) > maxLines {
500 maxLines = len(wrapped[i])
501 }
502 }
503 for line := range maxLines {
504 buf.WriteString(prefix)
505 for i := range cols {
506 if i > 0 {
507 buf.WriteString(sep)
508 }
509 var cell string
510 if line < len(wrapped[i]) {
511 cell = wrapped[i][line]
512 }
513 padded := padRight(cell, widths[i])
514 if isHeader {
515 padded = bold(padded)
516 }
517 buf.WriteString(padded)
518 }
519 buf.WriteByte('\n')
520 }
521 }
522
523 // collectCells walks a TableHeader / TableRow node and pulls each TableCell's
524 // inline content as an ANSI-styled string. Non-cell children are ignored.
525 func (r *mdRenderer) collectCells(parent ast.Node, src []byte) []string {
526 var out []string
527 for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
528 if cell, ok := c.(*extast.TableCell); ok {
529 out = append(out, strings.TrimSpace(r.collectInline(cell, src)))
530 }
531 }
532 return out
533 }
534
535 // inlineCarrier returns n when it's a paragraph or text-block (both hold
536 // inline runs), else nil. Used by list rendering so the marker line gets the
537 // inline content regardless of whether the list is tight or loose.
538 func inlineCarrier(n ast.Node) ast.Node {
539 switch n.(type) {
540 case *ast.Paragraph, *ast.TextBlock:
541 return n
542 }
543 return nil
544 }
545
546 // wrapAnsi word-wraps text to width columns, hard-breaking any single word too
547 // wide to fit on its own line — the path CJK takes, having no inter-word spaces.
548 // ANSI SGR escapes are preserved and counted as zero width; wide chars count as
549 // two columns. Thin wrapper over x/ansi's Wrap (already in the dep tree).
550 func wrapAnsi(text string, width int) string {
551 if width < 4 {
552 width = 4
553 }
554 return ansi.Wrap(text, width, "")
555 }
556
556 lines GO