Download
598 lines 16.1 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
package tui

import (
	"strings"

	"github.com/charmbracelet/x/ansi"
)

// markdown renders the Markdown models write, enough of it to read well in
// a terminal: headings, lists, quotes, rules, fenced code and tables, and
// bold, italic, strikethrough, code and links within a line. Lines stay as
// they are rather than being joined into paragraphs, since models break
// lines where they mean to. s must already be sanitized: the only escape
// sequences in what this returns are the styles it adds.
func markdown(s string, width int) string {
	width = max(width, 10)
	lines := strings.Split(s, "\n")
	var out []string
	for i := 0; i < len(lines); i++ {
		line := lines[i]
		trimmed := strings.TrimSpace(line)
		switch {
		case fenceOf(trimmed) != "":
			// Until the closing fence, or the end of a reply still
			// streaming in. The info string (a language, usually) shows
			// too: nothing the model wrote is hidden.
			fence := fenceOf(trimmed)
			if info := strings.TrimLeft(trimmed, fence[:1]); strings.TrimSpace(info) != "" {
				out = append(out, dimStyle.Render("  "+strings.TrimSpace(info)))
			}
			for i++; i < len(lines) && !closesFence(strings.TrimSpace(lines[i]), fence); i++ {
				for _, c := range strings.Split(ansi.Hardwrap(lines[i], width-2, true), "\n") {
					out = append(out, "  "+style{code: true}.sgr()+c+sgrReset)
				}
			}
		case tableStart(lines, i):
			aligns := alignments(lines[i+1])
			rows := [][]string{cells(line)}
			for i += 2; i < len(lines) && strings.Contains(lines[i], "|"); i++ {
				rows = append(rows, cells(lines[i]))
			}
			i--
			out = append(out, table(rows, aligns, width))
		case isRule(trimmed):
			out = append(out, dimStyle.Render(strings.Repeat("─", width)))
		case headingLevel(trimmed) > 0:
			n := headingLevel(trimmed)
			base := "1;94" // bold, in the accent color (ANSI 12)
			if n == 1 {
				base += ";4"
			}
			out = append(out, carry(ansi.Wrap(inline(strings.TrimSpace(trimmed[n:]), base), width, "")))
		case strings.HasPrefix(trimmed, ">"):
			quote := strings.TrimPrefix(strings.TrimPrefix(trimmed, ">"), " ")
			out = append(out, indent(carry(ansi.Wrap(inline(quote, "3"), width-2, "")), dimStyle.Render("│ ")))
		default:
			if lead, marker, text, ok := listItem(line); ok {
				prefix := strings.Repeat(" ", lead) + marker + " "
				w := ansi.StringWidth(prefix)
				body := carry(ansi.Wrap(inline(text, ""), width-w, ""))
				out = append(out, prefix+strings.ReplaceAll(body, "\n", "\n"+strings.Repeat(" ", w)))
				continue
			}
			out = append(out, carry(ansi.Wrap(inline(line, ""), width, "")))
		}
	}
	return strings.Join(out, "\n")
}

const sgrReset = "\x1b[0m"

// style is the styling at a point in a line.
type style struct {
	base                             string // SGR parameters for the whole line, such as "1" for bold
	bold, italic, strike, code, link bool
	mark                             bool // Markdown syntax, dimmed in the input
	sel                              bool // selected in the input
}

// markSGR colors Markdown syntax: like dimStyle, darker on a light
// background (see the BackgroundColorMsg case in Update).
var markSGR = "38;5;250"

// sgr is one escape sequence that sets exactly this style, so the last
// sequence before any point in a line says what style it has there.
func (st style) sgr() string {
	p := []string{"0"}
	if st.base != "" {
		p = append(p, st.base)
	}
	for _, a := range []struct {
		on   bool
		code string
	}{{st.bold, "1"}, {st.italic, "3"}, {st.strike, "9"}, {st.code, "36"}, {st.link, "4"}, {st.mark, markSGR}, {st.sel, "7"}} {
		if a.on {
			p = append(p, a.code)
		}
	}
	return "\x1b[" + strings.Join(p, ";") + "m"
}

// carry makes each line of styled text stand alone: it starts with the
// style the line before ended in and ends with a reset, so a line can be
// shown without the ones before it, and styles do not run into borders.
func carry(s string) string {
	if !strings.Contains(s, "\x1b") {
		return s
	}
	lines := strings.Split(s, "\n")
	state := ""
	for i, l := range lines {
		l = state + l
		if j := strings.LastIndex(l, "\x1b["); j >= 0 {
			if k := strings.IndexByte(l[j:], 'm'); k >= 0 {
				state = l[j : j+k+1]
			}
		}
		lines[i] = l + sgrReset
	}
	return strings.Join(lines, "\n")
}

// span is a run of a line's raw text and how it shows.
type span struct {
	text string
	st   style
	kind byte // 0 for text, 'm' for Markdown syntax, 'u' for a link's URL
	// The construct, such as a code span, that the syntax is part of,
	// numbered from 1: the input shows a construct's syntax only while
	// the cursor is within it.
	group int
	// The syntax opens a construct that is not closed yet, which runs to
	// the end of the line.
	unclosed bool
}

// spans parses the Markdown within a line, with base the SGR parameters
// for all of it. Their text joined is the line. In the input, cursor is
// where in the line the cursor is (-1 elsewhere): syntax before it that
// opens a construct opens it even if not closed yet, so the text being
// typed shows styled.
func spans(s, base string, cursor int) []span {
	st := style{base: base}
	var out []span
	group, groups := 0, 0
	var open [3]int // the groups of the bold, italic and strikethrough open
	add := func(text string, kind byte) {
		switch {
		case text == "":
		case kind == 0:
			out = append(out, span{text, st, kind, 0, false})
		case kind == 'm':
			out = append(out, span{text, style{base: base}, kind, group, false})
		default:
			out = append(out, span{text, st, kind, group, false})
		}
	}
	newGroup := func() {
		groups++
		group = groups
	}
	start := 0 // the start of plain text not yet added
	for i := 0; i < len(s); {
		c := s[i]
		switch {
		case c == '\\' && i+1 < len(s) && strings.IndexByte("\\`*_~[]()#|>-+.!", s[i+1]) >= 0:
			add(s[start:i], 0)
			newGroup()
			add(s[i:i+1], 'm')
			start = i + 1 // the escaped character starts the next text
			i += 2
			continue
		case c == '`':
			n := runOf(s[i:], '`')
			tick := s[i : i+n]
			end := strings.Index(s[i+n:], tick)
			if end < 0 && cursor >= i+n {
				add(s[start:i], 0)
				newGroup()
				add(tick, 'm')
				out[len(out)-1].unclosed = true
				st.code = true
				add(s[i+n:], 0)
				return out
			}
			if end < 0 {
				i += n
				continue
			}
			add(s[start:i], 0)
			code, opener, closer := s[i+n:i+n+end], tick, tick
			if len(code) > 2 && code[0] == ' ' && code[len(code)-1] == ' ' {
				code, opener, closer = code[1:len(code)-1], tick+" ", " "+tick
			}
			newGroup()
			add(opener, 'm')
			st.code = true
			add(code, 0)
			st.code = false
			add(closer, 'm')
			i += 2*n + end
			start = i
			continue
		case c == '[':
			if text, url, n, ok := link(s[i:]); ok {
				add(s[start:i], 0)
				newGroup()
				add("[", 'm')
				st.link = true
				add(text, 0)
				st.link = false
				if url == text {
					add("]("+url+")", 'm')
				} else {
					add("](", 'm')
					add(url, 'u')
					add(")", 'm')
				}
				i += n
				start = i
				continue
			}
		case c == '*' || c == '_' || c == '~':
			n := runOf(s[i:], c)
			if next := st; toggle(&next, s, i, n, cursor >= i+n) {
				add(s[start:i], 0)
				// The delimiters that close a style are in the group of
				// those that opened it, even when they differ, as *** and **.
				was := [3]bool{st.bold, st.italic, st.strike}
				now := [3]bool{next.bold, next.italic, next.strike}
				opening := now[0] && !was[0] || now[1] && !was[1] || now[2] && !was[2]
				if opening {
					newGroup()
				}
				for f := range open {
					switch {
					case now[f] && !was[f]:
						open[f] = group
					case was[f] && !now[f]:
						group = open[f]
					}
				}
				st = next
				add(s[i:i+n], 'm')
				out[len(out)-1].unclosed = opening && !strings.Contains(s[i+n:], s[i:i+n])
				start = i + n
			}
			i += n
			continue
		}
		i++
	}
	add(s[start:], 0)
	return out
}

// inline renders the Markdown within a line, with base the SGR parameters
// for all of it.
func inline(s, base string) string {
	var b strings.Builder
	cur := style{base: base}
	if base != "" {
		b.WriteString(cur.sgr())
	}
	for _, sp := range spans(s, base, -1) {
		text := sp.text
		switch sp.kind {
		case 'm':
			continue
		case 'u':
			text = " (" + text + ")"
		}
		if sp.st != cur {
			b.WriteString(sp.st.sgr())
			cur = sp.st
		}
		b.WriteString(text)
	}
	if strings.Contains(b.String(), "\x1b") {
		b.WriteString(sgrReset)
	}
	return b.String()
}

// toggle turns the style a run of n delimiters (*, _ or ~) at s[i] marks on
// or off, if it opens or closes one, and reports whether it did. With
// unclosed, it may open a style that nothing after it closes.
func toggle(st *style, s string, i, n int, unclosed bool) bool {
	c := s[i]
	var flags []*bool
	switch {
	case c == '~' && n == 2:
		flags = []*bool{&st.strike}
	case c == '~':
		return false
	case n == 1:
		flags = []*bool{&st.italic}
	case n == 2:
		flags = []*bool{&st.bold}
	case n == 3 && st.bold == st.italic:
		flags = []*bool{&st.bold, &st.italic}
	default:
		return false
	}
	var prev, next byte = ' ', ' '
	if i > 0 {
		prev = s[i-1]
	}
	if i+n < len(s) {
		next = s[i+n]
	}
	on := *flags[0]
	if on {
		// Closing: after text, and for _ not within a word.
		if prev == ' ' || c == '_' && isAlnum(next) {
			return false
		}
	} else {
		// Opening: before text that it closes after, and for _ not
		// within a word, so snake_case stays as it is.
		if next == ' ' || !unclosed && !strings.Contains(s[i+n:], s[i:i+n]) || c == '_' && isAlnum(prev) {
			return false
		}
	}
	for _, f := range flags {
		*f = !on
	}
	return true
}

// link parses [text](url) at the start of s, returning how long it is.
func link(s string) (text, url string, n int, ok bool) {
	mid := strings.Index(s, "](")
	if mid < 1 {
		return "", "", 0, false
	}
	end := strings.IndexByte(s[mid+2:], ')')
	if end < 1 {
		return "", "", 0, false
	}
	text, url = s[1:mid], s[mid+2:mid+2+end]
	if strings.ContainsAny(text, "[]") || strings.ContainsAny(url, " ()") {
		return "", "", 0, false
	}
	return text, url, mid + 3 + end, true
}

func runOf(s string, c byte) int {
	n := 0
	for n < len(s) && s[n] == c {
		n++
	}
	return n
}

func isAlnum(c byte) bool {
	return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80
}

// headingLevel is the number of #s starting a heading, or 0 for a line
// that is not one.
func headingLevel(t string) int {
	n := runOf(t, '#')
	if n == 0 || n > 6 || n < len(t) && t[n] != ' ' {
		return 0
	}
	return n
}

// fenceOf returns the fence a trimmed line opens a code block with, ``` or
// ~~~, or "" if it opens none.
func fenceOf(t string) string {
	if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
		return t[:3]
	}
	return ""
}

// closesFence reports whether a trimmed line closes a code block opened
// with fence: it is only fence characters. With more on the line, such as
// another block's info string, it is code.
func closesFence(t, fence string) bool {
	return strings.HasPrefix(t, fence) && strings.Trim(t, fence[:1]) == ""
}

// tableStart reports whether lines[i] is a table's header: a row with a
// delimiter row under it.
func tableStart(lines []string, i int) bool {
	return strings.Contains(lines[i], "|") && i+1 < len(lines) && isDelimiterRow(lines[i+1], len(cells(lines[i])))
}

// isRule reports whether a line is a thematic break: three or more of the
// same one of -, * or _, and maybe spaces.
func isRule(t string) bool {
	if len(t) < 3 || !strings.ContainsRune("-*_", rune(t[0])) {
		return false
	}
	n := 0
	for i := range len(t) {
		switch t[i] {
		case t[0]:
			n++
		case ' ':
		default:
			return false
		}
	}
	return n >= 3
}

// listItem parses a list item: its indentation, its marker (a bullet for
// -, * or +, or the number of a numbered one), and its text.
func listItem(l string) (lead int, marker, text string, ok bool) {
	n := listMarker(l)
	if n == 0 {
		return 0, "", "", false
	}
	lead = len(l) - len(strings.TrimLeft(l, " "))
	marker, text = l[lead:n-1], l[n:]
	if strings.IndexByte("-*+", marker[0]) < 0 {
		return lead, marker, text, true
	}
	switch {
	case strings.HasPrefix(text, "[ ] "):
		text = "☐ " + text[4:]
	case strings.HasPrefix(text, "[x] ") || strings.HasPrefix(text, "[X] "):
		text = "☑ " + text[4:]
	}
	return lead, "•", text, true
}

// listMarker is how long the start of a list item is: its indentation, its
// marker (-, * or +, or a number and . or ) after it) and a space. It is 0
// for a line that is not one.
func listMarker(l string) int {
	t := strings.TrimLeft(l, " ")
	lead := len(l) - len(t)
	if len(t) >= 2 && strings.IndexByte("-*+", t[0]) >= 0 && t[1] == ' ' {
		return lead + 2
	}
	n := 0
	for n < len(t) && n < 9 && t[n] >= '0' && t[n] <= '9' {
		n++
	}
	if n > 0 && n+1 < len(t) && (t[n] == '.' || t[n] == ')') && t[n+1] == ' ' {
		return lead + n + 2
	}
	return 0
}

// cells splits a table row at the |s not escaped or in code.
func cells(l string) []string {
	t := strings.TrimSpace(l)
	t = strings.TrimPrefix(t, "|")
	if strings.HasSuffix(t, "|") && !strings.HasSuffix(t, "\\|") {
		t = t[:len(t)-1]
	}
	var out []string
	start, code := 0, false
	for i := 0; i < len(t); i++ {
		switch t[i] {
		case '\\':
			i++
		case '`':
			code = !code
		case '|':
			if !code {
				out = append(out, strings.TrimSpace(t[start:i]))
				start = i + 1
			}
		}
	}
	return append(out, strings.TrimSpace(t[start:]))
}

// isDelimiterRow reports whether a line is the row under a table's header,
// such as |---|:--:|, for a header of n cells.
func isDelimiterRow(l string, n int) bool {
	if !strings.Contains(l, "-") {
		return false
	}
	cs := cells(l)
	if len(cs) != n {
		return false
	}
	for _, c := range cs {
		c = strings.TrimSuffix(strings.TrimPrefix(c, ":"), ":")
		if c == "" || strings.Trim(c, "-") != "" {
			return false
		}
	}
	return true
}

// alignments is how each column aligns, from the delimiter row: 'l', 'c'
// or 'r'.
func alignments(l string) []byte {
	var out []byte
	for _, c := range cells(l) {
		switch {
		case strings.HasPrefix(c, ":") && strings.HasSuffix(c, ":"):
			out = append(out, 'c')
		case strings.HasSuffix(c, ":"):
			out = append(out, 'r')
		default:
			out = append(out, 'l')
		}
	}
	return out
}

// table draws a table with borders, its header in bold, wrapping the text
// in its cells when it is too wide for the screen. A row with more cells
// than the header widens the table, rather than losing them as in GFM:
// nothing the model wrote may go unseen.
func table(rows [][]string, aligns []byte, width int) string {
	for _, row := range rows {
		for len(aligns) < len(row) {
			aligns = append(aligns, 'l')
		}
	}
	ncols := len(aligns)
	rendered := make([][]string, len(rows))
	widths := make([]int, ncols)
	for r, row := range rows {
		rendered[r] = make([]string, ncols)
		for j := range ncols {
			c := ""
			if j < len(row) {
				c = row[j]
			}
			base := ""
			if r == 0 {
				base = "1"
			}
			rendered[r][j] = inline(c, base)
			widths[j] = max(widths[j], ansi.StringWidth(rendered[r][j]), 1)
		}
	}
	// Each column takes its width plus " │ " between them, and "│ " and
	// " │" at the edges. Narrow the widest column until the table fits,
	// down to a few characters each.
	for {
		total, widest := 3*ncols+1, 0
		for j, w := range widths {
			total += w
			if w > widths[widest] {
				widest = j
			}
		}
		if total <= width || widths[widest] <= 3 {
			break
		}
		widths[widest]--
	}
	border := func(left, mid, right string) string {
		parts := make([]string, ncols)
		for j, w := range widths {
			parts[j] = strings.Repeat("─", w+2)
		}
		return dimStyle.Render(left + strings.Join(parts, mid) + right)
	}
	bar := dimStyle.Render("│")
	out := []string{border("┌", "┬", "┐")}
	for r, row := range rendered {
		wrapped := make([][]string, ncols)
		height := 1
		for j, c := range row {
			wrapped[j] = strings.Split(carry(ansi.Wrap(c, widths[j], "")), "\n")
			height = max(height, len(wrapped[j]))
		}
		for k := range height {
			var b strings.Builder
			b.WriteString(bar)
			for j := range ncols {
				c := ""
				if k < len(wrapped[j]) {
					c = wrapped[j][k]
				}
				pad := max(widths[j]-ansi.StringWidth(c), 0)
				left := 0
				switch aligns[j] {
				case 'r':
					left = pad
				case 'c':
					left = pad / 2
				}
				b.WriteString(" " + strings.Repeat(" ", left) + c + strings.Repeat(" ", pad-left) + " " + bar)
			}
			out = append(out, b.String())
		}
		if r == 0 {
			out = append(out, border("├", "┼", "┤"))
		}
	}
	out = append(out, border("└", "┴", "┘"))
	return strings.Join(out, "\n")
}