package tui import ( "crypto/sha256" "fmt" "regexp" "strings" "sync" "charm.land/glamour/v2" "charm.land/glamour/v2/ansi" "charm.land/glamour/v2/styles" "github.com/alecthomas/chroma/v2" chromastyles "github.com/alecthomas/chroma/v2/styles" ) // hashComments are the languages whose line comments start with "#". var hashComments = wordSet( "python", "py", "sh", "bash", "zsh", "shell", "nu", "nix", "toml", "yaml", "yml", "ruby", "rb", "elixir", ) // HighlightCode marks only comments. Every other token keeps the code's own // ink: highlighting has shown little measured benefit for comprehension, and // a hue per token class drowns the few hues that carry meaning here. // Comments stay readable because they are the notes in the code. func HighlightCode(code string, lang string) string { l := strings.ToLower(lang) if l == "" || l == "text" || l == "plain" || l == "txt" { return code } hash := hashComments[l] comment := faintInk() + ansiItalic var b strings.Builder chars := []rune(code) for i := 0; i < len(chars); { ch := chars[i] if (hash && ch == '#') || (!hash && ch == '/' && i+1 < len(chars) && chars[i+1] == '/') { j := i for j < len(chars) && chars[j] != '\n' { j++ } b.WriteString(comment) b.WriteString(string(chars[i:j])) b.WriteString(ansiReset) b.WriteString(codeInk()) i = j continue } if ch == '"' || ch == '\'' || ch == '`' { j := i + 1 for j < len(chars) && chars[j] != ch && chars[j] != '\n' { if chars[j] == '\\' { j++ } j++ } j = min(j+1, len(chars)) b.WriteString(string(chars[i:j])) i = j continue } b.WriteRune(ch) i++ } return b.String() } var markdownThemes sync.Mutex // fenceOpen and fenceClose mark the rows that frame a code block. const ( fenceOpen = "┌─" fenceClose = "└" ) // codeFence is a code block's frame row in Decor, and quoteBar leads each // quoted row. Both are decoration, which a copy does not carry. func codeFence(mark string) string { return decorInk() + mark + ansiReset } func quoteBar() string { return decorInk() + "│ " + ansiReset } // Chroma themes are registered by name; key them by the detected terminal // palette so a theme change cannot reuse the previous palette's colors. func codeTheme(colors ink) string { if colors.code == "" { return "monokai" } palette := colors.code + colors.secondary + colors.brandFrom + colors.brandTo hash := sha256.Sum256([]byte(palette)) name := fmt.Sprintf("albedo-%x", hash[:6]) markdownThemes.Lock() defer markdownThemes.Unlock() if chromastyles.Registry[name] == nil { chromastyles.Register(chroma.MustNewStyle(name, chroma.StyleEntries{ chroma.Text: colors.code, chroma.Comment: colors.secondary + " italic", chroma.Keyword: colors.brandFrom, chroma.NameFunction: colors.brandTo, chroma.LiteralString: colors.brandTo, chroma.LiteralNumber: colors.brandFrom, })) } return name } func markdownStyle() ansi.StyleConfig { style := styles.NoTTYStyleConfig zero, bold := uint(0), true style.Document.Margin = &zero style.Document.BlockPrefix, style.Document.BlockSuffix = "", "" style.Heading.BlockSuffix = "" for _, heading := range []*ansi.StyleBlock{&style.H1, &style.H2, &style.H3, &style.H4, &style.H5, &style.H6} { heading.Prefix = "" heading.Bold = &bold } style.Strong = ansi.StylePrimitive{Bold: &bold} style.Emph = ansi.StylePrimitive{Italic: &bold} style.Table.Margin = &zero style.BlockQuote.IndentToken = new(quoteBar()) style.CodeBlock.Margin = &zero style.CodeBlock.BlockPrefix = codeFence(fenceOpen) + "\n" style.CodeBlock.BlockSuffix = codeFence(fenceClose) style.HorizontalRule.Format = "\n────────────\n" if transcriptInk.decor != "" { style.HorizontalRule.Color = &transcriptInk.decor } style.CodeBlock.Theme = codeTheme(transcriptInk) return style } // RenderMarkdownAnsi renders text block by block. Glamour spaces a block by // the one before it and no further, so a finished block renders once and a // reply streaming in re-renders only its last block. func RenderMarkdownAnsi(text string, width int) string { if width <= 0 { width = 80 } return renderBlocks(text, width) } // renderCopyable renders text to width with its wrapped rows marked, so a // copy can join them back into the lines they were. func renderCopyable(text string, width int) string { return markWraps(RenderMarkdownAnsi(text, width), renderBlocks(text, 0)) } // renderBlocks is RenderMarkdownAnsi at width, or unwrapped at 0. func renderBlocks(text string, width int) string { blocks := markdownBlocks(text) var b strings.Builder for i, block := range blocks { prev := "" if i > 0 { prev = blocks[i-1] } piece, ok := markdownPiece(prev, block, width, i < len(blocks)-1) if !ok { return strings.Trim(renderMarkdown(text, width), " \n") } b.WriteString(piece) } return strings.Trim(b.String(), " \n") } // markdownPiece is what block adds to the rendering of prev, or false when // rendering prev on its own does not begin the rendering of both. Only a // finished block is kept: the last one may still be growing. func markdownPiece(prev, block string, width int, finished bool) (string, bool) { key := pieceKey{width: width, prev: prev, block: block} if piece, ok := pieces.get(key); ok { return piece, true } whole, head := renderMarkdown(prev+block, width), "" if prev != "" { head, _ = markdownPiece("", prev, width, true) } if !strings.HasPrefix(whole, head) { return "", false } piece := whole[len(head):] if finished { pieces.put(key, piece) } return piece, true } func renderMarkdown(text string, width int) string { renderer, err := glamour.NewTermRenderer( glamour.WithStyles(markdownStyle()), glamour.WithWordWrap(width), glamour.WithPreservedNewLines(), ) if err != nil { return text } markdownThemes.Lock() defer markdownThemes.Unlock() rendered, err := renderer.Render(text) if err != nil { return text } return rendered } var ( orderedItem = regexp.MustCompile(`^\d{1,9}[.)](\s|$)`) // Link definitions and raw HTML blocks can reach across blank lines. spansBlocks = regexp.MustCompile(`(?i)^ {0,3}(\[[^\]]+\]:|<(pre|script|style|textarea)\b|