Vectrum
Sign In
449 lines · 17.5 KB · Go
package tui

import (
	"strings"

	"github.com/alecthomas/chroma/v2"
	"github.com/alecthomas/chroma/v2/styles"
	"github.com/charmbracelet/glamour"
	glamourstyle "github.com/charmbracelet/glamour/ansi"
	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/x/ansi"
	"github.com/muesli/termenv"
)

// Assistant text is Markdown, so the transcript renders it as Markdown:
// headings become headings, ** becomes weight, a table becomes a table. The
// work is done by glamour, which is goldmark's CommonMark parser behind a
// terminal renderer; what this file owns is the stylesheet, which is derived
// from the theme in force so Markdown is in the same palette as everything
// else on the screen rather than in glamour's own.
//
// Only assistant text goes through here. Tool results, local command output
// and notices are program output, not prose, and are shown as they came.

// minMarkdownWidth is the point below which layout stops being worth
// attempting: a table or an indented code block in fifteen columns is noise,
// and plain wrapped text is the more useful thing to show.
const minMarkdownWidth = 20

// mdRenderers holds one renderer per width and colour depth: constructing one
// builds a goldmark pipeline and bakes the stylesheet in, and the transcript
// asks for a render on every streamed token.
var mdRenderers map[mdKey]*glamour.TermRenderer

// mdKey is everything a built renderer has baked in. The colour profile is
// part of it because a test that forces colour on, or a terminal that reports
// its depth late, must not be served a renderer built for the old answer.
type mdKey struct {
	width   int
	profile termenv.Profile
}

// renderMarkdown lays out assistant prose to fit width. It never fails in a
// way the reader can see: anything glamour cannot handle falls back to the
// plain wrap the transcript used before, because a message that renders badly
// is still better than a message that does not render.
func renderMarkdown(src string, width int) string {
	src = strings.TrimRight(src, "\n")
	if strings.TrimSpace(src) == "" {
		return ""
	}
	if width < minMarkdownWidth {
		return plainWrap(src, width)
	}
	r := markdownRenderer(width)
	if r == nil {
		return plainWrap(src, width)
	}
	out, err := r.Render(src)
	if err != nil {
		return plainWrap(src, width)
	}
	// glamour frames a document in blank lines. The transcript already
	// separates entries, so they would read as a gap that means nothing.
	return fit(strings.Trim(out, "\n"), width)
}

// fit squares glamour's output up with the columns we asked for.
//
// Two things need doing per line. The padding glamour lays down out to the
// wrap width comes off: it is invisible until someone selects the text, and
// then every line has a ragged tail of highlighted nothing. And a word too
// long to wrap — a URL, an import path, a hash — gets broken by force, because
// word wrapping leaves it hanging over the edge and the viewport then wraps
// it again at a column this layout knows nothing about.
func fit(s string, width int) string {
	var out []string
	for _, line := range strings.Split(s, "\n") {
		bare := strings.TrimRight(ansi.Strip(line), " \t")
		line = ansi.Truncate(line, ansi.StringWidth(bare), "")
		if ansi.StringWidth(line) <= width {
			out = append(out, compact(line))
			continue
		}
		for _, w := range strings.Split(ansi.Hardwrap(line, width, false), "\n") {
			out = append(out, compact(w))
		}
	}
	return strings.Join(out, "\n")
}

// compact drops the escape sequences in a line that paint nothing.
//
// glamour styles each word and each cell of padding separately, so one line of
// prose arrives as the same colour set and cleared thirty times over, followed
// by an escape per trimmed padding space. Stripping to the visible width
// leaves all of that behind — for a screenful of answer it is an order of
// magnitude more bytes of escape than of text, held for the whole session and
// re-scanned by the viewport on every frame.
//
// What survives is every escape that precedes text it actually changes, in
// order, and one reset to close the line.
func compact(line string) string {
	var (
		b       strings.Builder
		pending []string // escapes seen since the last visible text
		active  []string // what is in force on screen
		reset   bool     // a reset was seen and has not been emitted yet
	)
	for _, tok := range sgrTokens(line) {
		switch {
		case tok == "\x1b[0m" || tok == "\x1b[m":
			pending, reset = nil, len(active) > 0
		case strings.HasPrefix(tok, "\x1b[") && strings.HasSuffix(tok, "m"):
			pending = append(pending, tok)
		default:
			if reset {
				b.WriteString("\x1b[0m")
				active, reset = nil, false
			}
			for _, esc := range pending {
				b.WriteString(esc)
			}
			active = append(active, pending...)
			pending = nil
			b.WriteString(tok)
		}
	}
	if len(active) > 0 {
		b.WriteString("\x1b[0m")
	}
	return b.String()
}

// sgrTokens splits a line into SGR sequences and the text between them.
// Anything else escaped — a hyperlink, a cursor move — comes back as text, so
// it is passed through untouched rather than reasoned about.
func sgrTokens(s string) []string {
	var out []string
	text := 0
	for i := 0; i < len(s); {
		if s[i] != 0x1b || i+1 >= len(s) || s[i+1] != '[' {
			i++
			continue
		}
		j := i + 2
		for j < len(s) && (s[j] == ';' || (s[j] >= '0' && s[j] <= '9')) {
			j++
		}
		if j >= len(s) || s[j] != 'm' {
			i++
			continue
		}
		if text < i {
			out = append(out, s[text:i])
		}
		out = append(out, s[i:j+1])
		i, text = j+1, j+1
	}
	if text < len(s) {
		out = append(out, s[text:])
	}
	return out
}

// plainWrap is what the transcript did before Markdown, kept as the fallback.
func plainWrap(src string, width int) string {
	return styText.Width(maxInt(1, width)).Render(src)
}

func markdownRenderer(width int) *glamour.TermRenderer {
	// lipgloss's profile rather than termenv's own: glamour assumes truecolor,
	// and the rest of this UI paints with whatever lipgloss decided the
	// terminal can show. Two answers to that question on one screen is how
	// half a transcript ends up in colour and half of it not.
	key := mdKey{width: width, profile: lipgloss.ColorProfile()}
	if r, ok := mdRenderers[key]; ok {
		return r
	}
	r, err := glamour.NewTermRenderer(
		glamour.WithStyles(markdownStyle(currentTheme, key.profile, width)),
		glamour.WithWordWrap(width),
		glamour.WithColorProfile(key.profile),
		glamour.WithChromaFormatter(chromaFormatter(key.profile)),
	)
	if err != nil {
		return nil
	}
	if mdRenderers == nil {
		mdRenderers = map[mdKey]*glamour.TermRenderer{}
	}
	mdRenderers[key] = r
	return r
}

// resetMarkdown drops the renderers built against the previous palette, so the
// next draw builds them from the new one. applyTheme calls it.
func resetMarkdown() { mdRenderers = nil }

// chromaFormatter names the chroma writer that matches what the terminal can
// show. glamour defaults to terminal256, which would quantise this theme's
// colours to the nearest xterm slot and leave highlighted code a shade off
// every other thing on the screen.
func chromaFormatter(p termenv.Profile) string {
	switch p {
	case termenv.TrueColor:
		return "terminal16m"
	case termenv.ANSI:
		return "terminal"
	default:
		return "terminal256"
	}
}

// chromaThemes records which palettes have been handed to chroma's global
// registry, which is keyed by name and shared by every user of the package.
var chromaThemes = map[string]bool{}

// chromaTheme registers this theme's syntax colours with chroma and returns
// the name to ask for. It returns "" on a terminal that cannot show colour,
// which is how a code block is told to render plain: glamour only guards its
// own stylesheet against that case, not a theme named directly.
//
// The name is per-theme because chroma's registry is global and write-once in
// practice — glamour registers its own under the single name "charm" and skips
// the work if that name is taken, so a shared name would freeze code blocks in
// whichever palette happened to draw first.
func chromaTheme(t theme, p termenv.Profile) string {
	if p == termenv.Ascii {
		return ""
	}
	name := "harness-" + t.name
	if chromaThemes[name] {
		return name
	}
	c := markdownChroma(t)
	styles.Register(chroma.MustNewStyle(name, chroma.StyleEntries{
		chroma.Text:                chromaEntry(c.Text),
		chroma.Error:               chromaEntry(c.Error),
		chroma.Comment:             chromaEntry(c.Comment),
		chroma.CommentPreproc:      chromaEntry(c.CommentPreproc),
		chroma.Keyword:             chromaEntry(c.Keyword),
		chroma.KeywordReserved:     chromaEntry(c.KeywordReserved),
		chroma.KeywordNamespace:    chromaEntry(c.KeywordNamespace),
		chroma.KeywordType:         chromaEntry(c.KeywordType),
		chroma.Operator:            chromaEntry(c.Operator),
		chroma.Punctuation:         chromaEntry(c.Punctuation),
		chroma.Name:                chromaEntry(c.Name),
		chroma.NameBuiltin:         chromaEntry(c.NameBuiltin),
		chroma.NameTag:             chromaEntry(c.NameTag),
		chroma.NameAttribute:       chromaEntry(c.NameAttribute),
		chroma.NameClass:           chromaEntry(c.NameClass),
		chroma.NameConstant:        chromaEntry(c.NameConstant),
		chroma.NameDecorator:       chromaEntry(c.NameDecorator),
		chroma.NameException:       chromaEntry(c.NameException),
		chroma.NameFunction:        chromaEntry(c.NameFunction),
		chroma.NameOther:           chromaEntry(c.NameOther),
		chroma.Literal:             chromaEntry(c.Literal),
		chroma.LiteralNumber:       chromaEntry(c.LiteralNumber),
		chroma.LiteralDate:         chromaEntry(c.LiteralDate),
		chroma.LiteralString:       chromaEntry(c.LiteralString),
		chroma.LiteralStringEscape: chromaEntry(c.LiteralStringEscape),
		chroma.GenericDeleted:      chromaEntry(c.GenericDeleted),
		chroma.GenericInserted:     chromaEntry(c.GenericInserted),
		chroma.GenericEmph:         chromaEntry(c.GenericEmph),
		chroma.GenericStrong:       chromaEntry(c.GenericStrong),
		chroma.GenericSubheading:   chromaEntry(c.GenericSubheading),
	}))
	chromaThemes[name] = true
	return name
}

// markdownChroma maps chroma's token classes onto the same theme roles the
// rest of the UI uses, so highlighted code reads as part of this theme and not
// as a photograph of somebody else's editor.
func markdownChroma(t theme) glamourstyle.Chroma {
	var (
		text   = colorOf(t.text)
		dim    = colorOf(t.dim)
		accent = colorOf(t.accent)
		info   = colorOf(t.info)
		ok     = colorOf(t.ok)
		warn   = colorOf(t.warn)
		bad    = colorOf(t.err)
	)
	yes := ptr(true)
	return glamourstyle.Chroma{
		Text:                glamourstyle.StylePrimitive{Color: text},
		Error:               glamourstyle.StylePrimitive{Color: bad},
		Comment:             glamourstyle.StylePrimitive{Color: dim, Italic: yes},
		CommentPreproc:      glamourstyle.StylePrimitive{Color: warn},
		Keyword:             glamourstyle.StylePrimitive{Color: accent},
		KeywordReserved:     glamourstyle.StylePrimitive{Color: accent},
		KeywordNamespace:    glamourstyle.StylePrimitive{Color: accent},
		KeywordType:         glamourstyle.StylePrimitive{Color: info},
		Operator:            glamourstyle.StylePrimitive{Color: dim},
		Punctuation:         glamourstyle.StylePrimitive{Color: dim},
		Name:                glamourstyle.StylePrimitive{Color: text},
		NameBuiltin:         glamourstyle.StylePrimitive{Color: info},
		NameTag:             glamourstyle.StylePrimitive{Color: accent},
		NameAttribute:       glamourstyle.StylePrimitive{Color: info},
		NameClass:           glamourstyle.StylePrimitive{Color: info, Bold: yes},
		NameConstant:        glamourstyle.StylePrimitive{Color: warn},
		NameDecorator:       glamourstyle.StylePrimitive{Color: warn},
		NameException:       glamourstyle.StylePrimitive{Color: bad},
		NameFunction:        glamourstyle.StylePrimitive{Color: info},
		NameOther:           glamourstyle.StylePrimitive{Color: text},
		Literal:             glamourstyle.StylePrimitive{Color: ok},
		LiteralNumber:       glamourstyle.StylePrimitive{Color: warn},
		LiteralDate:         glamourstyle.StylePrimitive{Color: warn},
		LiteralString:       glamourstyle.StylePrimitive{Color: ok},
		LiteralStringEscape: glamourstyle.StylePrimitive{Color: warn},
		GenericDeleted:      glamourstyle.StylePrimitive{Color: bad},
		GenericInserted:     glamourstyle.StylePrimitive{Color: ok},
		GenericEmph:         glamourstyle.StylePrimitive{Italic: yes},
		GenericStrong:       glamourstyle.StylePrimitive{Bold: yes},
		GenericSubheading:   glamourstyle.StylePrimitive{Color: dim},
	}
}

// chromaEntry writes one token style in the "#rrggbb italic bold" grammar
// chroma parses its stylesheets from.
func chromaEntry(s glamourstyle.StylePrimitive) string {
	var parts []string
	if s.Color != nil {
		parts = append(parts, *s.Color)
	}
	if s.Italic != nil && *s.Italic {
		parts = append(parts, "italic")
	}
	if s.Bold != nil && *s.Bold {
		parts = append(parts, "bold")
	}
	return strings.Join(parts, " ")
}

// markdownStyle maps Markdown onto the ten theme roles.
//
// Headings carry no hashes. A hash is an instruction to make the line a
// heading, and having obeyed it there is no reason to print it too; the levels
// are told apart by colour and weight, the way the rest of this UI tells
// things apart.
func markdownStyle(t theme, profile termenv.Profile, width int) glamourstyle.StyleConfig {
	var (
		text   = colorOf(t.text)
		dim    = colorOf(t.dim)
		faint  = colorOf(t.faint)
		accent = colorOf(t.accent)
		info   = colorOf(t.info)
	)
	no, yes := ptr(false), ptr(true)

	return glamourstyle.StyleConfig{
		Document: glamourstyle.StyleBlock{
			StylePrimitive: glamourstyle.StylePrimitive{Color: text},
			// The transcript owns the left margin: everything hangs off the
			// dot in the gutter, and a second indent from glamour would put
			// prose out of line with every other kind of entry.
			Margin: ptr(uint(0)),
		},
		// Paragraph and Text are deliberately left bare. glamour applies
		// Styles.Text to every text run as an override, so a colour here would
		// win over the heading, the link and the blockquote alike and flatten
		// the whole document back to one colour. Document above is the base;
		// everything else is a departure from it.
		Paragraph: glamourstyle.StyleBlock{},

		Heading: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{
			Color: accent, Bold: yes, BlockSuffix: "\n",
		}},
		H1: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: accent, Bold: yes}},
		H2: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: accent, Bold: yes}},
		H3: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: text, Bold: yes}},
		H4: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: text, Bold: yes}},
		H5: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: dim, Bold: yes}},
		H6: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: dim, Bold: no}},

		Strong:        glamourstyle.StylePrimitive{Bold: yes},
		Emph:          glamourstyle.StylePrimitive{Italic: yes},
		Strikethrough: glamourstyle.StylePrimitive{CrossedOut: yes, Color: dim},

		BlockQuote: glamourstyle.StyleBlock{
			StylePrimitive: glamourstyle.StylePrimitive{Color: dim, Italic: yes},
			Indent:         ptr(uint(1)),
			IndentToken:    ptr("│ "),
		},

		List:        glamourstyle.StyleList{LevelIndent: 2},
		Item:        glamourstyle.StylePrimitive{BlockPrefix: "• "},
		Enumeration: glamourstyle.StylePrimitive{BlockPrefix: ". "},
		Task:        glamourstyle.StyleTask{Ticked: "[✓] ", Unticked: "[ ] "},

		Link:     glamourstyle.StylePrimitive{Color: info, Underline: yes},
		LinkText: glamourstyle.StylePrimitive{Color: text},
		Image:    glamourstyle.StylePrimitive{Color: info, Underline: yes},
		ImageText: glamourstyle.StylePrimitive{
			Color: dim, Format: "Image: {{.text}} →",
		},

		// A rule divides the page, so it is drawn as wide as the page. The
		// stylesheet can say that because it is built per width, the same as
		// the renderer that holds it.
		HorizontalRule: glamourstyle.StylePrimitive{
			Color: faint, Format: "\n" + strings.Repeat("─", maxInt(1, width)) + "\n",
		},

		// Inline code takes the accent so a literal token stands out of the
		// sentence around it. A background colour would be the obvious other
		// choice, and is what glamour ships, but it tears across a wrap.
		Code: glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: accent}},

		CodeBlock: glamourstyle.StyleCodeBlock{
			StyleBlock: glamourstyle.StyleBlock{
				StylePrimitive: glamourstyle.StylePrimitive{Color: text},
				Margin:         ptr(uint(2)),
			},
			// Named rather than handed over inline: glamour would register an
			// inline Chroma block under one global name shared with every
			// other program using it. See chromaTheme.
			Theme: chromaTheme(t, profile),
		},

		// The rules take the dim grey the rest of the UI draws structure in,
		// leaving the cells at the document's own colour.
		Table: glamourstyle.StyleTable{
			StyleBlock:      glamourstyle.StyleBlock{StylePrimitive: glamourstyle.StylePrimitive{Color: dim}},
			CenterSeparator: ptr("┼"),
			ColumnSeparator: ptr("│"),
			RowSeparator:    ptr("─"),
		},

		DefinitionTerm:        glamourstyle.StylePrimitive{Bold: yes},
		DefinitionDescription: glamourstyle.StylePrimitive{BlockPrefix: "\n  "},
	}
}

// colorOf resolves an adaptive colour to the one hex value glamour can take.
// glamour's stylesheet is strings, so the light/dark choice has to be made
// here rather than at draw time; it is remade whenever the theme is applied,
// which is the only moment the answer can change.
func colorOf(c lipgloss.AdaptiveColor) *string {
	s := c.Light
	if lipgloss.HasDarkBackground() {
		s = c.Dark
	}
	return &s
}

func ptr[T any](v T) *T { return &v }