diff --git a/ui/panels/chat.go b/ui/panels/chat.go index a19d566..4082470 100644 --- a/ui/panels/chat.go +++ b/ui/panels/chat.go @@ -128,7 +128,9 @@ func renderUserMsg(content string, width int) string { } func renderAgentMsg(content string, width int) string { - return renderPrefixedBlock(agentStyle.Render(content), width, " ", " ") + firstPrefix := " " + restPrefix := " " + return renderPrefixedLines(renderAgentContent(content, prefixedBodyWidth(width, firstPrefix, restPrefix)), firstPrefix, restPrefix) } func renderThinking(thinkingView string, width int) string { @@ -231,16 +233,13 @@ func nonEmptyLines(content string) []string { } func renderPrefixedBlock(content string, width int, firstPrefix, restPrefix string) string { - prefixWidth := lipgloss.Width(firstPrefix) - if w := lipgloss.Width(restPrefix); w > prefixWidth { - prefixWidth = w - } - bodyWidth := width - prefixWidth - if bodyWidth < 1 { - bodyWidth = 1 - } + bodyWidth := prefixedBodyWidth(width, firstPrefix, restPrefix) wrapped := lipgloss.NewStyle().Width(bodyWidth).Render(content) - lines := strings.Split(wrapped, "\n") + return renderPrefixedLines(wrapped, firstPrefix, restPrefix) +} + +func renderPrefixedLines(content, firstPrefix, restPrefix string) string { + lines := strings.Split(content, "\n") for i := range lines { if i == 0 { lines[i] = firstPrefix + lines[i] @@ -251,6 +250,18 @@ func renderPrefixedBlock(content string, width int, firstPrefix, restPrefix stri return strings.Join(lines, "\n") } +func prefixedBodyWidth(width int, firstPrefix, restPrefix string) int { + prefixWidth := lipgloss.Width(firstPrefix) + if w := lipgloss.Width(restPrefix); w > prefixWidth { + prefixWidth = w + } + bodyWidth := width - prefixWidth + if bodyWidth < 1 { + bodyWidth = 1 + } + return bodyWidth +} + func renderToolHeader(icon, title string, borderStyle, titleStyle lipgloss.Style, width int) string { dividerWidth := width - lipgloss.Width(title) - 6 if dividerWidth < 6 { diff --git a/ui/panels/chat_test.go b/ui/panels/chat_test.go index 9ebd767..f45a20a 100644 --- a/ui/panels/chat_test.go +++ b/ui/panels/chat_test.go @@ -1,12 +1,15 @@ package panels import ( + "regexp" "strings" "testing" "github.com/vigo999/mindspore-code/ui/model" ) +var testANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + func TestRenderMessages_ToolPendingShowsOneCallLine(t *testing.T) { state := model.State{ Messages: []model.Message{ @@ -78,3 +81,258 @@ func TestRenderMessages_ToolFailureShowsErrorSummaryAndDetails(t *testing.T) { t.Fatalf("expected failure detail line, got:\n%s", view) } } + +func TestRenderMessagesRendersAgentMarkdown(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "# Title\n\n- item one\n1. item two\n\n`inline`\n\n```go\nfmt.Println(\"hi\")\n```" + + "\n\n[docs](https://example.com)", + }) + + rendered := RenderMessages(state, "", 80) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + if strings.Contains(plain, "# Title") { + t.Fatalf("expected heading markers to be removed, got:\n%s", plain) + } + if strings.Contains(plain, "- item one") { + t.Fatalf("expected bullet markers to be rendered, got:\n%s", plain) + } + if strings.Contains(plain, "```") { + t.Fatalf("expected code fences to be removed, got:\n%s", plain) + } + for _, want := range []string{"Title", "• item one", "1. item two", "inline", "fmt.Println(\"hi\")", "docs (https://example.com)"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } +} + +func TestRenderMessagesRendersMarkdownTable(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "| 类别 | 内容 |\n" + + "|------|------|\n" + + "| 核心入口 | cmd/ - 命令行命令定义 |\n" + + "| 业务模块 | agent/ - AI Agent 相关(含8个skill)、runtime/ - 运行时、workflow/ - 工作流 |", + }) + + rendered := RenderMessages(state, "", 120) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + for _, want := range []string{"┌", "┐", "类别", "内容", "核心入口", "业务模块", "cmd/ - 命令行命令定义"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } + if strings.Contains(plain, "|------|") { + t.Fatalf("expected markdown separator row to be hidden, got:\n%s", plain) + } +} + +func TestRenderMessagesRendersTaskAndNestedLists(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "- [ ] todo\n" + + "- [x] done\n" + + " - child item\n" + + " 1. ordered child", + }) + + rendered := RenderMessages(state, "", 100) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + for _, want := range []string{"[ ] todo", "[x] done", " • child item", " 1. ordered child"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } +} + +func TestRenderMessagesRendersCodeFenceLangAndStrikethrough(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "~~deprecated~~ and __bold__ and _italic_\n\n```bash\necho hi\n```", + }) + + rendered := RenderMessages(state, "", 100) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + for _, want := range []string{"deprecated", "bold", "italic", "bash", "echo hi"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } + if strings.Contains(plain, "```bash") { + t.Fatalf("expected fenced code marker to be hidden, got:\n%s", plain) + } +} + +func TestRenderMessagesInlineCodeAndFenceMarkers(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "Use `` and `inline` here.\n\n```txt\nnot a fence marker\n```", + }) + + rendered := RenderMessages(state, "", 80) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + for _, want := range []string{"", "inline", "txt", "not a fence marker"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } + if strings.Contains(plain, "`") { + t.Fatalf("expected inline and fenced code markers to be hidden, got:\n%s", plain) + } +} + +func TestRenderMessagesKeepsWideTableBordersStable(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "| Name | Description | Notes |\n" + + "| ---- | ----------- | ----- |\n" + + "| alpha | this cell is intentionally very wide to exercise truncation | keep border stable |\n" + + "| beta | another wide value that used to trigger outer wrapping | second row |", + }) + + rendered := RenderMessages(state, "", 42) + plain := testANSIPattern.ReplaceAllString(rendered, "") + lines := strings.Split(plain, "\n") + + var tableLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "┌") || strings.HasPrefix(trimmed, "├") || strings.HasPrefix(trimmed, "└") || strings.HasPrefix(trimmed, "│") { + tableLines = append(tableLines, trimmed) + } + } + if len(tableLines) < 5 { + t.Fatalf("expected full rendered table, got:\n%s", plain) + } + for _, fragment := range []string{"┐", "┤", "┘"} { + if !strings.Contains(plain, fragment) { + t.Fatalf("expected %q in rendered output, got:\n%s", fragment, plain) + } + } + counts := map[string]int{"┌": 0, "├": 0, "└": 0} + for _, line := range tableLines { + switch { + case strings.HasPrefix(line, "┌"): + counts["┌"]++ + case strings.HasPrefix(line, "├"): + counts["├"]++ + case strings.HasPrefix(line, "└"): + counts["└"]++ + } + } + for border, count := range counts { + if count != 1 { + t.Fatalf("expected exactly one %s border line, got %d in:\n%s", border, count, plain) + } + } + for _, want := range []string{"this cell is", "intentionall", "another wide", "wrapping"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected wrapped table content %q, got:\n%s", want, plain) + } + } +} + +func TestRenderMessagesRendersCodeBlockAsDistinctBlock(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "before\n\n```py\nprint(\"hi\")\n```\n\nafter", + }) + + rendered := RenderMessages(state, "", 60) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + for _, want := range []string{"before", "py", "┃ print(\"hi\")", "after"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } + if strings.Contains(plain, "```py") { + t.Fatalf("expected fenced code marker to be hidden, got:\n%s", plain) + } +} + +func TestRenderMessagesRendersTableInlineCodeWithoutBreakingCodeSpan(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "| File | Description |\n" + + "| ---- | ----------- |\n" + + "| `manager_test.go` | Tests for context manager |", + }) + + rendered := RenderMessages(state, "", 38) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + if strings.Contains(plain, "manager_test.g\no") { + t.Fatalf("expected inline code token to stay on one table line, got:\n%s", plain) + } + if !strings.Contains(plain, "manager") { + t.Fatalf("expected file name content to remain visible, got:\n%s", plain) + } +} + +func TestRenderMessagesWrapsParagraphListQuoteCodeAndRule(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "This paragraph should wrap across multiple lines in the chat panel.\n\n" + + "- bullet item that should wrap and keep continuation aligned.\n" + + "- [ ] task item that should also wrap neatly in narrow widths.\n" + + "1. ordered item that should wrap while preserving the numeric prefix.\n\n" + + "> quoted text should wrap and keep the quote rail aligned across lines.\n\n" + + "---\n\n" + + "```txt\nthis-code-line-is-long-enough-to-wrap-inside-the-code-block\n```", + }) + + rendered := RenderMessages(state, "", 34) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + for _, want := range []string{ + "This paragraph should wrap", + "across multiple lines in the", + "• bullet item that should wrap", + "and keep continuation aligned.", + "[ ] task item that should also", + "1. ordered item that should wrap", + "│ quoted text should wrap and", + "│ keep the quote rail aligned", + "this-code-line-is-long-enough", + "-to-wrap-inside-the-code-bloc", + } { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } + + if strings.Contains(plain, "this-code-line-is-long-enough-to-wrap-inside-the-code-block") { + t.Fatalf("expected code block line to wrap instead of remaining on one line, got:\n%s", plain) + } + + lines := strings.Split(plain, "\n") + ruleFound := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "────") { + ruleFound = true + if len([]rune(trimmed)) < 20 { + t.Fatalf("expected width-aware rule line, got:\n%s", plain) + } + } + } + if !ruleFound { + t.Fatalf("expected rendered rule line, got:\n%s", plain) + } +} diff --git a/ui/panels/markdown.go b/ui/panels/markdown.go new file mode 100644 index 0000000..fa67e3c --- /dev/null +++ b/ui/panels/markdown.go @@ -0,0 +1,849 @@ +package panels + +import ( + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + +var ( + ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + + mdHeading1Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("230")). + Bold(true) + + mdHeading2Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("223")). + Bold(true) + + mdHeading3Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Bold(true) + + mdQuoteStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Italic(true) + + mdRuleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("239")) + + mdCodeBlockStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("254")). + BorderLeft(true). + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("67")). + PaddingLeft(1). + PaddingRight(1) + + mdCodeInlineStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("60")) + + mdBoldStyle = lipgloss.NewStyle().Bold(true) + + mdItalicStyle = lipgloss.NewStyle().Italic(true) + + mdStrikeStyle = lipgloss.NewStyle().Strikethrough(true) + + mdLinkStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("117")). + Underline(true) + + mdLinkURLStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("244")) + + mdTableBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + mdTableHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("230")). + Bold(true) + + mdCodeLangStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Background(lipgloss.Color("236")). + Bold(true). + PaddingLeft(1). + PaddingRight(1) +) + +func renderAgentContent(content string, width int) string { + if content == "" { + return "" + } + if ansiEscapePattern.MatchString(content) { + return content + } + return renderMarkdown(content, width) +} + +func renderMarkdown(content string, width int) string { + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + rendered := make([]string, 0, len(lines)) + inCodeBlock := false + codeLang := "" + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "```") { + if !inCodeBlock { + codeLang = strings.TrimSpace(strings.TrimPrefix(trimmed, "```")) + if codeLang != "" { + rendered = append(rendered, mdCodeLangStyle.Render(codeLang)) + } + } + inCodeBlock = !inCodeBlock + if !inCodeBlock { + codeLang = "" + } + continue + } + if inCodeBlock { + rendered = append(rendered, renderCodeBlockLine(line, width)...) + continue + } + if trimmed == "" { + rendered = append(rendered, "") + continue + } + if block, next, ok := markdownTable(lines, i, width); ok { + rendered = append(rendered, block) + i = next - 1 + continue + } + if heading, level, ok := markdownHeading(line); ok { + rendered = append(rendered, markdownHeadingStyle(level).Render(renderInlineMarkdown(heading))) + continue + } + if quote, ok := markdownQuote(line); ok { + rendered = append(rendered, renderWrappedMarkdownWithPrefix(quote, width, "│ ", "│ ")...) + continue + } + if indent, checked, item, ok := markdownTaskItem(line); ok { + rendered = append(rendered, renderWrappedMarkdownWithPrefix(item, width, renderListPrefix(indent, taskListMarker(checked)), renderListContinuationPrefix(indent, taskListMarker(checked)))...) + continue + } + if indent, item, ok := markdownBullet(line); ok { + rendered = append(rendered, renderWrappedMarkdownWithPrefix(item, width, renderListPrefix(indent, "• "), renderListContinuationPrefix(indent, "• "))...) + continue + } + if indent, index, item, ok := markdownOrderedItem(line); ok { + rendered = append(rendered, renderWrappedMarkdownWithPrefix(item, width, renderListPrefix(indent, index+". "), renderListContinuationPrefix(indent, index+". "))...) + continue + } + if markdownRule(trimmed) { + rendered = append(rendered, renderMarkdownRule(width)) + continue + } + rendered = append(rendered, wrapRenderedLine(renderInlineMarkdown(line), width)...) + } + + return strings.Join(rendered, "\n") +} + +type tableAlignment int + +const ( + alignLeft tableAlignment = iota + alignCenter + alignRight +) + +func markdownTable(lines []string, start int, width int) (string, int, bool) { + if start+1 >= len(lines) { + return "", start, false + } + header := strings.TrimSpace(lines[start]) + separator := strings.TrimSpace(lines[start+1]) + if !isMarkdownTableRow(header) || !isMarkdownTableSeparator(separator) { + return "", start, false + } + + rows := [][]string{parseMarkdownTableRow(header)} + i := start + 2 + for i < len(lines) { + trimmed := strings.TrimSpace(lines[i]) + if trimmed == "" || !isMarkdownTableRow(trimmed) { + break + } + rows = append(rows, parseMarkdownTableRow(trimmed)) + i++ + } + if len(rows) < 2 { + return "", start, false + } + return renderMarkdownTable(rows, parseMarkdownTableAlignment(separator), width), i, true +} + +func isMarkdownTableRow(line string) bool { + line = strings.TrimSpace(line) + return strings.Count(line, "|") >= 2 +} + +func isMarkdownTableSeparator(line string) bool { + if !isMarkdownTableRow(line) { + return false + } + cells := parseMarkdownTableRow(line) + if len(cells) == 0 { + return false + } + for _, cell := range cells { + cell = strings.TrimSpace(cell) + if cell == "" { + return false + } + for _, r := range cell { + if r != '-' && r != ':' { + return false + } + } + } + return true +} + +func parseMarkdownTableRow(line string) []string { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "|") { + line = strings.TrimPrefix(line, "|") + } + if strings.HasSuffix(line, "|") { + line = strings.TrimSuffix(line, "|") + } + parts := strings.Split(line, "|") + out := make([]string, 0, len(parts)) + for _, part := range parts { + out = append(out, strings.TrimSpace(part)) + } + return out +} + +func renderMarkdownTable(rows [][]string, aligns []tableAlignment, width int) string { + colCount := 0 + for _, row := range rows { + if len(row) > colCount { + colCount = len(row) + } + } + widths := make([]int, colCount) + for _, row := range rows { + for col := 0; col < colCount; col++ { + cell := "" + if col < len(row) { + cell = row[col] + } + if w := plainTextWidth(renderInlineMarkdown(cell)); w > widths[col] { + widths[col] = w + } + } + } + widths = constrainTableWidths(widths, width) + + lines := []string{renderTableBorder("┌", "┬", "┐", widths), renderTableRow(rows[0], widths, true, aligns), renderTableBorder("├", "┼", "┤", widths)} + for _, row := range rows[1:] { + lines = append(lines, renderTableRow(row, widths, false, aligns)) + } + lines = append(lines, renderTableBorder("└", "┴", "┘", widths)) + return strings.Join(lines, "\n") +} + +func renderTableBorder(left, middle, right string, widths []int) string { + parts := make([]string, 0, len(widths)*2+1) + parts = append(parts, left) + for i, width := range widths { + if i > 0 { + parts = append(parts, middle) + } + parts = append(parts, strings.Repeat("─", width+2)) + } + parts = append(parts, right) + return mdTableBorderStyle.Render(strings.Join(parts, "")) +} + +func renderTableRow(row []string, widths []int, header bool, aligns ...[]tableAlignment) string { + rowLines := make([][]string, len(widths)) + rowHeight := 1 + var colAligns []tableAlignment + if len(aligns) > 0 { + colAligns = aligns[0] + } + for i, width := range widths { + text := "" + if i < len(row) { + text = row[i] + } + rowLines[i] = wrapRenderedTableCell(text, width) + if len(rowLines[i]) > rowHeight { + rowHeight = len(rowLines[i]) + } + } + + lines := make([]string, 0, rowHeight) + for lineIndex := 0; lineIndex < rowHeight; lineIndex++ { + var b strings.Builder + b.WriteString(mdTableBorderStyle.Render("│")) + for col, cellWidth := range widths { + cellLine := "" + if lineIndex < len(rowLines[col]) { + cellLine = rowLines[col][lineIndex] + } + leftPad, rightPad := tablePadding(cellWidth, plainTextWidth(cellLine), alignTableCell(header, colAligns, col)) + b.WriteString(" ") + b.WriteString(strings.Repeat(" ", leftPad)) + if header { + b.WriteString(mdTableHeaderStyle.Render(cellLine)) + } else { + b.WriteString(cellLine) + } + b.WriteString(strings.Repeat(" ", rightPad)) + b.WriteString(" ") + b.WriteString(mdTableBorderStyle.Render("│")) + } + lines = append(lines, b.String()) + } + return strings.Join(lines, "\n") +} + +func alignTableCell(header bool, aligns []tableAlignment, col int) tableAlignment { + if header { + return alignLeft + } + if col >= 0 && col < len(aligns) { + return aligns[col] + } + return alignLeft +} + +func wrapRenderedTableCell(text string, width int) []string { + if width <= 0 { + return []string{""} + } + text = strings.TrimSpace(text) + if text == "" { + return []string{""} + } + + var lines []string + for _, paragraph := range strings.Split(text, "\n") { + if paragraph == "" { + lines = append(lines, "") + continue + } + for _, segment := range wrapTableText(paragraph, width) { + lines = append(lines, renderInlineMarkdown(segment)) + } + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func wrapTableText(text string, width int) []string { + if width <= 0 { + return []string{""} + } + if runewidth.StringWidth(text) <= width { + return []string{text} + } + + words := strings.Fields(text) + if len(words) == 0 { + return splitPlainText(text, width) + } + + lines := make([]string, 0, 1) + current := "" + for _, word := range words { + wordWidth := runewidth.StringWidth(word) + if current == "" { + if wordWidth <= width { + current = word + continue + } + if isInlineCodeToken(word) { + current = truncateInlineCodeToken(word, width) + continue + } + parts := splitPlainText(word, width) + lines = append(lines, parts[:len(parts)-1]...) + current = parts[len(parts)-1] + continue + } + + candidate := current + " " + word + if runewidth.StringWidth(candidate) <= width { + current = candidate + continue + } + + lines = append(lines, current) + if wordWidth <= width { + current = word + continue + } + if isInlineCodeToken(word) { + current = truncateInlineCodeToken(word, width) + continue + } + parts := splitPlainText(word, width) + lines = append(lines, parts[:len(parts)-1]...) + current = parts[len(parts)-1] + } + if current != "" { + lines = append(lines, current) + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func isInlineCodeToken(token string) bool { + return len(token) >= 2 && strings.HasPrefix(token, "`") && strings.HasSuffix(token, "`") && strings.Count(token, "`") == 2 +} + +func truncateInlineCodeToken(token string, width int) string { + if width <= 0 { + return "" + } + if runewidth.StringWidth(token) <= width { + return token + } + if width <= 3 { + return strings.Repeat("…", 1) + } + + content := token[1 : len(token)-1] + innerWidth := width - 2 + if innerWidth <= 1 { + return "`…`" + } + parts := splitPlainText(content, innerWidth-1) + truncated := "" + if len(parts) > 0 { + truncated = parts[0] + } + return "`" + truncated + "…`" +} +func splitPlainText(text string, width int) []string { + if width <= 0 { + return []string{""} + } + var ( + parts []string + b strings.Builder + used int + ) + for _, r := range text { + rw := runewidth.RuneWidth(r) + if used > 0 && used+rw > width { + parts = append(parts, b.String()) + b.Reset() + used = 0 + } + b.WriteRune(r) + used += rw + } + if b.Len() > 0 { + parts = append(parts, b.String()) + } + if len(parts) == 0 { + return []string{""} + } + return parts +} + +func constrainTableWidths(widths []int, maxWidth int) []int { + if len(widths) == 0 { + return widths + } + if maxWidth < minimumTableWidth(len(widths)) { + maxWidth = minimumTableWidth(len(widths)) + } + constrained := append([]int(nil), widths...) + for tableRenderWidth(constrained) > maxWidth { + col := widestColumnIndex(constrained) + if constrained[col] <= 3 { + break + } + constrained[col]-- + } + return constrained +} + +func tableRenderWidth(widths []int) int { + width := 1 + for _, colWidth := range widths { + width += colWidth + 3 + } + return width +} + +func minimumTableWidth(cols int) int { + if cols <= 0 { + return 1 + } + return 1 + cols*3 +} + +func widestColumnIndex(widths []int) int { + best := 0 + for i := 1; i < len(widths); i++ { + if widths[i] > widths[best] { + best = i + } + } + return best +} + +func truncatePlainText(text string, width int) string { + if width <= 0 { + return "" + } + if runewidth.StringWidth(text) <= width { + return text + } + parts := splitPlainText(text, width) + if len(parts) == 0 { + return "" + } + return parts[0] +} + +func renderCodeBlockLine(line string, width int) []string { + contentWidth := width - codeBlockDecorationWidth() + if contentWidth < 1 { + contentWidth = 1 + } + parts := splitPlainText(line, contentWidth) + lines := make([]string, 0, len(parts)) + for _, part := range parts { + lines = append(lines, mdCodeBlockStyle.Render(part)) + } + if len(lines) == 0 { + return []string{mdCodeBlockStyle.Render("")} + } + return lines +} + +func codeBlockDecorationWidth() int { + return mdCodeBlockStyle.GetHorizontalFrameSize() +} + +func plainTextWidth(rendered string) int { + return runewidth.StringWidth(ansiEscapePattern.ReplaceAllString(rendered, "")) +} + +func markdownHeading(line string) (string, int, bool) { + trimmed := strings.TrimLeft(line, " \t") + level := 0 + for level < len(trimmed) && trimmed[level] == '#' { + level++ + } + if level == 0 || level > 6 || len(trimmed) <= level || trimmed[level] != ' ' { + return "", 0, false + } + return strings.TrimSpace(trimmed[level:]), level, true +} + +func markdownHeadingStyle(level int) lipgloss.Style { + switch level { + case 1: + return mdHeading1Style + case 2: + return mdHeading2Style + default: + return mdHeading3Style + } +} + +func markdownQuote(line string) (string, bool) { + trimmed := strings.TrimLeft(line, " \t") + if !strings.HasPrefix(trimmed, ">") { + return "", false + } + return strings.TrimSpace(strings.TrimPrefix(trimmed, ">")), true +} + +func markdownBullet(line string) (int, string, bool) { + indent, trimmed := markdownIndent(line) + if len(trimmed) < 2 { + return 0, "", false + } + switch trimmed[0] { + case '-', '*', '+': + if trimmed[1] == ' ' { + return indent, strings.TrimSpace(trimmed[2:]), true + } + } + return 0, "", false +} + +func markdownTaskItem(line string) (int, bool, string, bool) { + indent, trimmed := markdownIndent(line) + if len(trimmed) < 6 { + return 0, false, "", false + } + if (trimmed[0] != '-' && trimmed[0] != '*' && trimmed[0] != '+') || trimmed[1] != ' ' || trimmed[2] != '[' || trimmed[4] != ']' || trimmed[5] != ' ' { + return 0, false, "", false + } + switch trimmed[3] { + case ' ', 'x', 'X': + return indent, trimmed[3] == 'x' || trimmed[3] == 'X', strings.TrimSpace(trimmed[6:]), true + default: + return 0, false, "", false + } +} + +func markdownOrderedItem(line string) (int, string, string, bool) { + indent, trimmed := markdownIndent(line) + i := 0 + for i < len(trimmed) && trimmed[i] >= '0' && trimmed[i] <= '9' { + i++ + } + if i == 0 || i+1 >= len(trimmed) || trimmed[i] != '.' || trimmed[i+1] != ' ' { + return 0, "", "", false + } + return indent, trimmed[:i], strings.TrimSpace(trimmed[i+2:]), true +} + +func markdownRule(line string) bool { + if len(line) < 3 { + return false + } + switch line[0] { + case '-', '*', '_': + for i := 1; i < len(line); i++ { + if line[i] != line[0] { + return false + } + } + return true + default: + return false + } +} + +func renderInlineMarkdown(line string) string { + var out strings.Builder + for len(line) > 0 { + switch { + case strings.HasPrefix(line, "~~"): + if text, rest, ok := markdownDelimited(line, "~~"); ok { + out.WriteString(mdStrikeStyle.Render(renderInlineMarkdown(text))) + line = rest + continue + } + case strings.HasPrefix(line, "`"): + end := strings.Index(line[1:], "`") + if end >= 0 { + end++ + out.WriteString(mdCodeInlineStyle.Render(line[1:end])) + line = line[end+1:] + continue + } + case strings.HasPrefix(line, "**"): + if text, rest, ok := markdownDelimited(line, "**"); ok { + out.WriteString(mdBoldStyle.Render(renderInlineMarkdown(text))) + line = rest + continue + } + case strings.HasPrefix(line, "__"): + if text, rest, ok := markdownDelimited(line, "__"); ok { + out.WriteString(mdBoldStyle.Render(renderInlineMarkdown(text))) + line = rest + continue + } + case strings.HasPrefix(line, "*"): + if text, rest, ok := markdownDelimited(line, "*"); ok { + out.WriteString(mdItalicStyle.Render(renderInlineMarkdown(text))) + line = rest + continue + } + case strings.HasPrefix(line, "_"): + if text, rest, ok := markdownDelimited(line, "_"); ok { + out.WriteString(mdItalicStyle.Render(renderInlineMarkdown(text))) + line = rest + continue + } + case strings.HasPrefix(line, "["): + if label, url, rest, ok := markdownLink(line); ok { + out.WriteString(mdLinkStyle.Render(renderInlineMarkdown(label))) + if url != "" { + out.WriteString(mdLinkURLStyle.Render(" (" + url + ")")) + } + line = rest + continue + } + } + + next := nextMarkdownToken(line) + plain := line + if next > 0 { + plain = line[:next] + line = line[next:] + } else { + line = "" + } + out.WriteString(agentStyle.Render(plain)) + } + return out.String() +} + +func markdownDelimited(line, delim string) (string, string, bool) { + if !strings.HasPrefix(line, delim) { + return "", "", false + } + end := strings.Index(line[len(delim):], delim) + if end < 0 { + return "", "", false + } + end += len(delim) + return line[len(delim):end], line[end+len(delim):], true +} + +func markdownLink(line string) (string, string, string, bool) { + closeLabel := strings.Index(line, "](") + if closeLabel <= 1 { + return "", "", "", false + } + closeURL := strings.Index(line[closeLabel+2:], ")") + if closeURL < 0 { + return "", "", "", false + } + closeURL += closeLabel + 2 + return line[1:closeLabel], line[closeLabel+2 : closeURL], line[closeURL+1:], true +} + +func nextMarkdownToken(line string) int { + indexes := []int{ + strings.Index(line, "~~"), + strings.Index(line, "`"), + strings.Index(line, "**"), + strings.Index(line, "__"), + strings.Index(line, "*"), + strings.Index(line, "_"), + strings.Index(line, "["), + } + best := -1 + for _, idx := range indexes { + if idx < 0 { + continue + } + if best < 0 || idx < best { + best = idx + } + } + return best +} + +func markdownIndent(line string) (int, string) { + spaces := 0 + for _, r := range line { + if r == ' ' { + spaces++ + continue + } + if r == '\t' { + spaces += 2 + continue + } + break + } + return spaces / 2, strings.TrimLeft(line, " \t") +} + +func renderListPrefix(indent int, marker string) string { + return agentStyle.Render(strings.Repeat(" ", indent) + marker) +} + +func renderListContinuationPrefix(indent int, marker string) string { + return strings.Repeat(" ", runewidth.StringWidth(strings.Repeat(" ", indent)+marker)) +} + +func renderWrappedMarkdownWithPrefix(line string, width int, firstPrefix, restPrefix string) []string { + rendered := wrapRenderedLine(renderInlineMarkdown(line), width-lipgloss.Width(firstPrefix)) + lines := make([]string, 0, len(rendered)) + for i, part := range rendered { + if i == 0 { + lines = append(lines, firstPrefix+part) + continue + } + lines = append(lines, restPrefix+part) + } + if len(lines) == 0 { + return []string{firstPrefix} + } + return lines +} + +func wrapRenderedLine(line string, width int) []string { + if width < 1 { + width = 1 + } + return strings.Split(lipgloss.NewStyle().Width(width).Render(line), "\n") +} + +func renderMarkdownRule(width int) string { + if width < 3 { + width = 3 + } + return mdRuleStyle.Render(strings.Repeat("─", width)) +} + +func taskListMarker(checked bool) string { + if checked { + return "[x] " + } + return "[ ] " +} + +func parseMarkdownTableAlignment(line string) []tableAlignment { + cells := parseMarkdownTableRow(line) + aligns := make([]tableAlignment, 0, len(cells)) + for _, cell := range cells { + cell = strings.TrimSpace(cell) + left := strings.HasPrefix(cell, ":") + right := strings.HasSuffix(cell, ":") + switch { + case left && right: + aligns = append(aligns, alignCenter) + case right: + aligns = append(aligns, alignRight) + default: + aligns = append(aligns, alignLeft) + } + } + return aligns +} + +func alignmentAt(aligns []tableAlignment, col int, header bool) tableAlignment { + if header { + return alignCenter + } + if col >= 0 && col < len(aligns) { + return aligns[col] + } + return alignLeft +} + +func tablePadding(cellWidth, contentWidth int, align tableAlignment) (int, int) { + if contentWidth >= cellWidth { + return 0, 0 + } + space := cellWidth - contentWidth + switch align { + case alignRight: + return space, 0 + case alignCenter: + left := space / 2 + return left, space - left + default: + return 0, space + } +}