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 }