mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
321 lines
8.3 KiB
Go
321 lines
8.3 KiB
Go
package render
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Markdown (agent_message_renderer.py)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var blankLineRuns = regexp.MustCompile(`\n\s*\n`)
|
|
|
|
type mdHeader struct {
|
|
prefix string
|
|
strip int
|
|
style lipgloss.Style
|
|
}
|
|
|
|
var mdHeaders = []mdHeader{
|
|
{"###### ", 7, Bold(Field)},
|
|
{"##### ", 6, Bold(Green)},
|
|
{"#### ", 5, Bold(Hdr16a)},
|
|
{"### ", 4, Bold(Hdr158)},
|
|
{"## ", 3, Bold(Green)},
|
|
{"# ", 2, Bold(Field)},
|
|
}
|
|
|
|
// renderAssistantMarkdown ports AgentMessageRenderer.render_simple + helpers.
|
|
func renderAssistantMarkdown(content string) string {
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
cleaned := strings.TrimSpace(blankLineRuns.ReplaceAllString(content, "\n\n"))
|
|
if cleaned == "" {
|
|
return ""
|
|
}
|
|
return applyMarkdownStyles(cleaned)
|
|
}
|
|
|
|
func applyMarkdownStyles(text string) string {
|
|
var out strings.Builder
|
|
lines := strings.Split(text, "\n")
|
|
|
|
inCode := false
|
|
codeLang := ""
|
|
var codeLines []string
|
|
|
|
flushCode := func() {
|
|
if len(codeLines) > 0 {
|
|
out.WriteString(HighlightCode(strings.Join(codeLines, "\n"), codeLang))
|
|
}
|
|
codeLines = nil
|
|
codeLang = ""
|
|
}
|
|
|
|
for i := 0; i < len(lines); i++ {
|
|
line := lines[i]
|
|
if i > 0 && !inCode {
|
|
out.WriteString("\n")
|
|
}
|
|
|
|
if !inCode {
|
|
if rows := tableRows(lines[i:]); rows > 0 {
|
|
out.WriteString(renderMarkdownTable(lines[i : i+rows]))
|
|
i += rows - 1
|
|
continue
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(line, "```") {
|
|
if !inCode {
|
|
inCode = true
|
|
codeLines = nil
|
|
codeLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
|
|
if i > 0 {
|
|
out.WriteString("\n")
|
|
}
|
|
} else {
|
|
inCode = false
|
|
flushCode()
|
|
}
|
|
continue
|
|
}
|
|
|
|
if inCode {
|
|
codeLines = append(codeLines, line)
|
|
continue
|
|
}
|
|
|
|
if h := tryHeader(line); h != nil {
|
|
out.WriteString(h.style.Render(line[h.strip:]))
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(line, "> "):
|
|
out.WriteString(Col(Green).Render("┃ ") + inlineFormat(line[2:]))
|
|
case strings.HasPrefix(line, "- "), strings.HasPrefix(line, "* "):
|
|
out.WriteString(Col(Green).Render("• ") + inlineFormat(line[2:]))
|
|
case len(line) > 2 && line[0] >= '0' && line[0] <= '9' && (line[1:3] == ". " || line[1:3] == ") "):
|
|
out.WriteString(Col(Green).Render(string(line[0])+". ") + inlineFormat(line[2:]))
|
|
case line == "---" || line == "***" || line == "___":
|
|
out.WriteString(Col(Green).Render(strings.Repeat("─", 40)))
|
|
default:
|
|
out.WriteString(inlineFormat(line))
|
|
}
|
|
}
|
|
|
|
if inCode && len(codeLines) > 0 {
|
|
flushCode()
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func isTableRow(line string) bool {
|
|
trimmed := strings.TrimSpace(line)
|
|
return strings.HasPrefix(trimmed, "|") && strings.Count(trimmed, "|") >= 2
|
|
}
|
|
|
|
var tableSeparatorCell = regexp.MustCompile(`^:?-+:?$`)
|
|
|
|
func isTableSeparator(line string) bool {
|
|
if !isTableRow(line) {
|
|
return false
|
|
}
|
|
cells := splitTableRow(line)
|
|
if len(cells) == 0 {
|
|
return false
|
|
}
|
|
for _, cell := range cells {
|
|
if !tableSeparatorCell.MatchString(strings.TrimSpace(cell)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// tableRows returns how many leading lines form a markdown table (header,
|
|
// separator, then body rows), or 0 when the block is not a table.
|
|
func tableRows(lines []string) int {
|
|
if len(lines) < 2 || !isTableRow(lines[0]) || !isTableSeparator(lines[1]) {
|
|
return 0
|
|
}
|
|
rows := 2
|
|
for rows < len(lines) && isTableRow(lines[rows]) && !isTableSeparator(lines[rows]) {
|
|
rows++
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func splitTableRow(line string) []string {
|
|
trimmed := strings.TrimSpace(line)
|
|
trimmed = strings.TrimPrefix(trimmed, "|")
|
|
trimmed = strings.TrimSuffix(trimmed, "|")
|
|
cells := strings.Split(trimmed, "|")
|
|
for i := range cells {
|
|
cells[i] = strings.TrimSpace(cells[i])
|
|
}
|
|
return cells
|
|
}
|
|
|
|
// renderMarkdownTable draws a column-aligned table: bold header, a rule under
|
|
// it, and inline-formatted body cells.
|
|
func renderMarkdownTable(lines []string) string {
|
|
headerStyle := func(cell string) string { return Bold(Field).Render(cell) }
|
|
rows := make([][]string, 0, len(lines)-1)
|
|
styleCells := func(line string, style func(string) string) []string {
|
|
cells := splitTableRow(line)
|
|
for i := range cells {
|
|
cells[i] = style(cells[i])
|
|
}
|
|
return cells
|
|
}
|
|
rows = append(rows, styleCells(lines[0], headerStyle))
|
|
for _, line := range lines[2:] {
|
|
rows = append(rows, styleCells(line, inlineFormat))
|
|
}
|
|
|
|
widths := make([]int, len(rows[0]))
|
|
for _, cells := range rows {
|
|
for i, cell := range cells {
|
|
if i < len(widths) {
|
|
widths[i] = max(widths[i], lipgloss.Width(cell))
|
|
}
|
|
}
|
|
}
|
|
|
|
formatRow := func(cells []string) string {
|
|
parts := make([]string, len(widths))
|
|
for i := range widths {
|
|
cell := ""
|
|
if i < len(cells) {
|
|
cell = cells[i]
|
|
}
|
|
parts[i] = cell + strings.Repeat(" ", max(0, widths[i]-lipgloss.Width(cell)))
|
|
}
|
|
return strings.TrimRight(strings.Join(parts, Dim().Render(" │ ")), " ")
|
|
}
|
|
|
|
out := []string{formatRow(rows[0])}
|
|
rule := make([]string, len(widths))
|
|
for i, width := range widths {
|
|
rule[i] = strings.Repeat("─", width)
|
|
}
|
|
out = append(out, Dim().Render(strings.Join(rule, "─┼─")))
|
|
for _, cells := range rows[1:] {
|
|
out = append(out, formatRow(cells))
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
func tryHeader(line string) *mdHeader {
|
|
for i := range mdHeaders {
|
|
if strings.HasPrefix(line, mdHeaders[i].prefix) {
|
|
return &mdHeaders[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isWordByte(b byte) bool {
|
|
return b == '_' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
|
|
}
|
|
|
|
// canOpenEmphasis reports whether an emphasis run starting at i (with the
|
|
// given marker width) follows CommonMark-style flanking rules: it must not
|
|
// sit inside a word and must be followed by a non-space.
|
|
func canOpenEmphasis(line string, i, width int) bool {
|
|
if i > 0 && isWordByte(line[i-1]) {
|
|
return false
|
|
}
|
|
// Underscores appear inside identifiers far more often than as emphasis,
|
|
// so they only open at a word boundary.
|
|
if i > 0 && line[i] == '_' && line[i-1] != ' ' && line[i-1] != '\t' {
|
|
return false
|
|
}
|
|
after := i + width
|
|
return after < len(line) && line[after] != ' ' && line[after] != '\t'
|
|
}
|
|
|
|
// canCloseEmphasis reports whether an emphasis run ending at end (marker
|
|
// starts at end) is preceded by a non-space and not followed by a word.
|
|
func canCloseEmphasis(line string, end, width int) bool {
|
|
if end > 0 && (line[end-1] == ' ' || line[end-1] == '\t') {
|
|
return false
|
|
}
|
|
after := end + width
|
|
return after >= len(line) || !isWordByte(line[after])
|
|
}
|
|
|
|
// findEmphasisEnd locates the closing marker for an emphasis span opened at
|
|
// i, honoring the flanking rules; returns -1 when the span should be treated
|
|
// as literal text.
|
|
func findEmphasisEnd(line string, i int, marker string) int {
|
|
from := i + len(marker)
|
|
for {
|
|
end := strings.Index(line[from:], marker)
|
|
if end == -1 {
|
|
return -1
|
|
}
|
|
end += from
|
|
if end == i+len(marker) {
|
|
return -1
|
|
}
|
|
if canCloseEmphasis(line, end, len(marker)) {
|
|
return end
|
|
}
|
|
from = end + 1
|
|
}
|
|
}
|
|
|
|
// inlineFormat ports _process_inline_formatting.
|
|
func inlineFormat(line string) string {
|
|
var out strings.Builder
|
|
i, n := 0, len(line)
|
|
for i < n {
|
|
if i+1 < n && (line[i:i+2] == "**" || line[i:i+2] == "__") {
|
|
marker := line[i : i+2]
|
|
if canOpenEmphasis(line, i, 2) {
|
|
if end := findEmphasisEnd(line, i, marker); end != -1 {
|
|
out.WriteString(Bold(Field).Render(line[i+2 : end]))
|
|
i = end + 2
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if i+1 < n && line[i:i+2] == "~~" {
|
|
if canOpenEmphasis(line, i, 2) {
|
|
if end := findEmphasisEnd(line, i, "~~"); end != -1 {
|
|
out.WriteString(lipgloss.NewStyle().Strikethrough(true).Foreground(Strike).Render(line[i+2 : end]))
|
|
i = end + 2
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if line[i] == '`' {
|
|
if end := strings.Index(line[i+1:], "`"); end != -1 {
|
|
end += i + 1
|
|
out.WriteString(lipgloss.NewStyle().Bold(true).Foreground(Green).Background(CodeBg).Render(line[i+1 : end]))
|
|
i = end + 1
|
|
continue
|
|
}
|
|
}
|
|
if line[i] == '*' || line[i] == '_' {
|
|
marker := string(line[i])
|
|
if i+1 < n && line[i+1] != line[i] && canOpenEmphasis(line, i, 1) {
|
|
if end := findEmphasisEnd(line, i, marker); end != -1 && (end+1 >= n || line[end+1] != line[i]) {
|
|
out.WriteString(lipgloss.NewStyle().Italic(true).Foreground(Mint).Render(line[i+1 : end]))
|
|
i = end + 1
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
out.WriteByte(line[i])
|
|
i++
|
|
}
|
|
return out.String()
|
|
}
|