From db1119c094467c54888b0ba07a5c1e34905ba17f Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:09:14 +0800 Subject: [PATCH 1/6] fix: render markdown in chat panel Co-Authored-By: Claude Sonnet 4.6 --- ui/panels/chat.go | 2 +- ui/panels/chat_test.go | 115 +++++++++ ui/panels/markdown.go | 570 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 ui/panels/markdown.go diff --git a/ui/panels/chat.go b/ui/panels/chat.go index a19d566..d9b4641 100644 --- a/ui/panels/chat.go +++ b/ui/panels/chat.go @@ -128,7 +128,7 @@ func renderUserMsg(content string, width int) string { } func renderAgentMsg(content string, width int) string { - return renderPrefixedBlock(agentStyle.Render(content), width, " ", " ") + return renderPrefixedBlock(renderAgentContent(content), width, " ", " ") } func renderThinking(thinkingView string, width int) string { diff --git a/ui/panels/chat_test.go b/ui/panels/chat_test.go index 9ebd767..d490b90 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,115 @@ 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 TestRenderMessagesRendersTableAlignmentSyntax(t *testing.T) { + state := model.NewState("test", ".", "", "demo-model", 4096) + state = state.WithMessage(model.Message{ + Kind: model.MsgAgent, + Content: "| left | center | right |\n" + + "| :--- | :----: | ----: |\n" + + "| a | bb | ccc |", + }) + + rendered := RenderMessages(state, "", 100) + plain := testANSIPattern.ReplaceAllString(rendered, "") + + for _, want := range []string{"left", "center", "right", "a", "bb", "ccc"} { + if !strings.Contains(plain, want) { + t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) + } + } + if strings.Contains(plain, ":----:") { + t.Fatalf("expected alignment separator row to be hidden, got:\n%s", plain) + } +} diff --git a/ui/panels/markdown.go b/ui/panels/markdown.go new file mode 100644 index 0000000..289a9cd --- /dev/null +++ b/ui/panels/markdown.go @@ -0,0 +1,570 @@ +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("252")). + Background(lipgloss.Color("236")). + PaddingLeft(1). + PaddingRight(1) + + mdCodeInlineStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("238")) + + 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) string { + if content == "" { + return "" + } + if ansiEscapePattern.MatchString(content) { + return content + } + return renderMarkdown(content) +} + +func renderMarkdown(content string) 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, mdCodeBlockStyle.Render(line)) + continue + } + if trimmed == "" { + rendered = append(rendered, "") + continue + } + if block, next, ok := markdownTable(lines, i); 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, mdQuoteStyle.Render("│ "+renderInlineMarkdown(quote))) + continue + } + if indent, checked, item, ok := markdownTaskItem(line); ok { + rendered = append(rendered, renderListPrefix(indent, taskListMarker(checked))+renderInlineMarkdown(item)) + continue + } + if indent, item, ok := markdownBullet(line); ok { + rendered = append(rendered, renderListPrefix(indent, "• ")+renderInlineMarkdown(item)) + continue + } + if indent, index, item, ok := markdownOrderedItem(line); ok { + rendered = append(rendered, renderListPrefix(indent, index+". ")+renderInlineMarkdown(item)) + continue + } + if markdownRule(trimmed) { + rendered = append(rendered, mdRuleStyle.Render("────────────────────")) + continue + } + rendered = append(rendered, renderInlineMarkdown(line)) + } + + return strings.Join(rendered, "\n") +} + +type tableAlignment int + +const ( + alignLeft tableAlignment = iota + alignCenter + alignRight +) + +func markdownTable(lines []string, start 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)), 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) 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 + } + } + } + + 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 { + var b strings.Builder + b.WriteString(mdTableBorderStyle.Render("│")) + var colAligns []tableAlignment + if len(aligns) > 0 { + colAligns = aligns[0] + } + for i, width := range widths { + text := "" + if i < len(row) { + text = row[i] + } + rendered := renderInlineMarkdown(text) + leftPad, rightPad := tablePadding(width, plainTextWidth(rendered), alignmentAt(colAligns, i, header)) + b.WriteString(" ") + b.WriteString(strings.Repeat(" ", leftPad)) + if header { + b.WriteString(mdTableHeaderStyle.Render(rendered)) + } else { + b.WriteString(rendered) + } + b.WriteString(strings.Repeat(" ", rightPad)) + b.WriteString(" ") + b.WriteString(mdTableBorderStyle.Render("│")) + } + return b.String() +} + +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 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 + } +} From 9d9bbc090ce8a5b32f1506e4027580d49931f55e Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:37:03 +0800 Subject: [PATCH 2/6] fix: table and highlight styles --- ui/panels/chat.go | 31 ++++++++---- ui/panels/chat_test.go | 87 ++++++++++++++++++++++++++++++++ ui/panels/markdown.go | 109 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 204 insertions(+), 23 deletions(-) diff --git a/ui/panels/chat.go b/ui/panels/chat.go index d9b4641..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(renderAgentContent(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 d490b90..33e92e9 100644 --- a/ui/panels/chat_test.go +++ b/ui/panels/chat_test.go @@ -172,6 +172,93 @@ func TestRenderMessagesRendersCodeFenceLangAndStrikethrough(t *testing.T) { } } +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) + } + } +} + +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 TestRenderMessagesRendersTableAlignmentSyntax(t *testing.T) { state := model.NewState("test", ".", "", "demo-model", 4096) state = state.WithMessage(model.Message{ diff --git a/ui/panels/markdown.go b/ui/panels/markdown.go index 289a9cd..561f5ea 100644 --- a/ui/panels/markdown.go +++ b/ui/panels/markdown.go @@ -31,14 +31,17 @@ var ( Foreground(lipgloss.Color("239")) mdCodeBlockStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")). - Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("254")). + Background(lipgloss.Color("235")). + BorderLeft(true). + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("67")). PaddingLeft(1). PaddingRight(1) mdCodeInlineStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("238")) + Foreground(lipgloss.Color("230")). + Background(lipgloss.Color("60")) mdBoldStyle = lipgloss.NewStyle().Bold(true) @@ -68,17 +71,17 @@ var ( PaddingRight(1) ) -func renderAgentContent(content string) string { +func renderAgentContent(content string, width int) string { if content == "" { return "" } if ansiEscapePattern.MatchString(content) { return content } - return renderMarkdown(content) + return renderMarkdown(content, width) } -func renderMarkdown(content string) string { +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 @@ -102,14 +105,14 @@ func renderMarkdown(content string) string { continue } if inCodeBlock { - rendered = append(rendered, mdCodeBlockStyle.Render(line)) + rendered = append(rendered, renderCodeBlockLine(line, width)) continue } if trimmed == "" { rendered = append(rendered, "") continue } - if block, next, ok := markdownTable(lines, i); ok { + if block, next, ok := markdownTable(lines, i, width); ok { rendered = append(rendered, block) i = next - 1 continue @@ -152,7 +155,7 @@ const ( alignRight ) -func markdownTable(lines []string, start int) (string, int, bool) { +func markdownTable(lines []string, start int, width int) (string, int, bool) { if start+1 >= len(lines) { return "", start, false } @@ -175,7 +178,7 @@ func markdownTable(lines []string, start int) (string, int, bool) { if len(rows) < 2 { return "", start, false } - return renderMarkdownTable(rows, parseMarkdownTableAlignment(separator)), i, true + return renderMarkdownTable(rows, parseMarkdownTableAlignment(separator), width), i, true } func isMarkdownTableRow(line string) bool { @@ -221,7 +224,7 @@ func parseMarkdownTableRow(line string) []string { return out } -func renderMarkdownTable(rows [][]string, aligns []tableAlignment) string { +func renderMarkdownTable(rows [][]string, aligns []tableAlignment, width int) string { colCount := 0 for _, row := range rows { if len(row) > colCount { @@ -240,6 +243,7 @@ func renderMarkdownTable(rows [][]string, aligns []tableAlignment) string { } } } + widths = constrainTableWidths(widths, width) lines := []string{renderTableBorder("┌", "┬", "┐", widths), renderTableRow(rows[0], widths, true, aligns), renderTableBorder("├", "┼", "┤", widths)} for _, row := range rows[1:] { @@ -274,7 +278,7 @@ func renderTableRow(row []string, widths []int, header bool, aligns ...[]tableAl if i < len(row) { text = row[i] } - rendered := renderInlineMarkdown(text) + rendered := renderInlineMarkdown(truncatePlainText(text, width)) leftPad, rightPad := tablePadding(width, plainTextWidth(rendered), alignmentAt(colAligns, i, header)) b.WriteString(" ") b.WriteString(strings.Repeat(" ", leftPad)) @@ -290,6 +294,85 @@ func renderTableRow(row []string, widths []int, header bool, aligns ...[]tableAl return b.String() } +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 + } + if width == 1 { + return "…" + } + limit := width - 1 + var b strings.Builder + used := 0 + for _, r := range text { + rw := runewidth.RuneWidth(r) + if used+rw > limit { + break + } + b.WriteRune(r) + used += rw + } + return b.String() + "…" +} + +func renderCodeBlockLine(line string, width int) string { + contentWidth := width - codeBlockDecorationWidth() + if contentWidth < 1 { + contentWidth = 1 + } + return mdCodeBlockStyle.Render(truncatePlainText(line, contentWidth)) +} + +func codeBlockDecorationWidth() int { + return mdCodeBlockStyle.GetHorizontalFrameSize() +} + func plainTextWidth(rendered string) int { return runewidth.StringWidth(ansiEscapePattern.ReplaceAllString(rendered, "")) } From 0b1d3d1b957e4c5775a189781a9d1647a7f4be80 Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:19:53 +0800 Subject: [PATCH 3/6] fix: remove background color for mdCodeBlock and CodeInline styles --- ui/panels/markdown.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/panels/markdown.go b/ui/panels/markdown.go index 561f5ea..2dc4e94 100644 --- a/ui/panels/markdown.go +++ b/ui/panels/markdown.go @@ -32,7 +32,6 @@ var ( mdCodeBlockStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("254")). - Background(lipgloss.Color("235")). BorderLeft(true). BorderStyle(lipgloss.ThickBorder()). BorderForeground(lipgloss.Color("67")). @@ -40,8 +39,7 @@ var ( PaddingRight(1) mdCodeInlineStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("230")). - Background(lipgloss.Color("60")) + Foreground(lipgloss.Color("60")) mdBoldStyle = lipgloss.NewStyle().Bold(true) From 41544b6ddcc2c52481e0a6b355aa9e6ef9ebfa84 Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:41:06 +0800 Subject: [PATCH 4/6] fix: table style shows ... --- ui/panels/chat_test.go | 5 ++ ui/panels/markdown.go | 175 ++++++++++++++++++++++++++++++++++------- 2 files changed, 153 insertions(+), 27 deletions(-) diff --git a/ui/panels/chat_test.go b/ui/panels/chat_test.go index 33e92e9..4a50506 100644 --- a/ui/panels/chat_test.go +++ b/ui/panels/chat_test.go @@ -237,6 +237,11 @@ func TestRenderMessagesKeepsWideTableBordersStable(t *testing.T) { 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) { diff --git a/ui/panels/markdown.go b/ui/panels/markdown.go index 2dc4e94..a104a62 100644 --- a/ui/panels/markdown.go +++ b/ui/panels/markdown.go @@ -265,8 +265,8 @@ func renderTableBorder(left, middle, right string, widths []int) string { } func renderTableRow(row []string, widths []int, header bool, aligns ...[]tableAlignment) string { - var b strings.Builder - b.WriteString(mdTableBorderStyle.Render("│")) + rowLines := make([][]string, len(widths)) + rowHeight := 1 var colAligns []tableAlignment if len(aligns) > 0 { colAligns = aligns[0] @@ -276,20 +276,151 @@ func renderTableRow(row []string, widths []int, header bool, aligns ...[]tableAl if i < len(row) { text = row[i] } - rendered := renderInlineMarkdown(truncatePlainText(text, width)) - leftPad, rightPad := tablePadding(width, plainTextWidth(rendered), alignmentAt(colAligns, i, header)) - b.WriteString(" ") - b.WriteString(strings.Repeat(" ", leftPad)) - if header { - b.WriteString(mdTableHeaderStyle.Render(rendered)) - } else { - b.WriteString(rendered) + rowLines[i] = wrapRenderedTableCell(text, width) + if len(rowLines[i]) > rowHeight { + rowHeight = len(rowLines[i]) } - b.WriteString(strings.Repeat(" ", rightPad)) - b.WriteString(" ") + } + + 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) } - return b.String() + + lines := make([]string, 0, 1) + current := "" + for _, word := range words { + wordWidth := runewidth.StringWidth(word) + if current == "" { + if wordWidth <= width { + current = word + 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 + } + 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 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 { @@ -342,21 +473,11 @@ func truncatePlainText(text string, width int) string { if runewidth.StringWidth(text) <= width { return text } - if width == 1 { - return "…" - } - limit := width - 1 - var b strings.Builder - used := 0 - for _, r := range text { - rw := runewidth.RuneWidth(r) - if used+rw > limit { - break - } - b.WriteRune(r) - used += rw + parts := splitPlainText(text, width) + if len(parts) == 0 { + return "" } - return b.String() + "…" + return parts[0] } func renderCodeBlockLine(line string, width int) string { From ba8274636b6c0dcf780ec3f64eea8197409a2d69 Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:51:15 +0800 Subject: [PATCH 5/6] fix: table style - long-word breaks into two pieces --- ui/panels/chat_test.go | 20 +++++++++----------- ui/panels/markdown.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/ui/panels/chat_test.go b/ui/panels/chat_test.go index 4a50506..efafec5 100644 --- a/ui/panels/chat_test.go +++ b/ui/panels/chat_test.go @@ -264,24 +264,22 @@ func TestRenderMessagesRendersCodeBlockAsDistinctBlock(t *testing.T) { } } -func TestRenderMessagesRendersTableAlignmentSyntax(t *testing.T) { +func TestRenderMessagesRendersTableInlineCodeWithoutBreakingCodeSpan(t *testing.T) { state := model.NewState("test", ".", "", "demo-model", 4096) state = state.WithMessage(model.Message{ Kind: model.MsgAgent, - Content: "| left | center | right |\n" + - "| :--- | :----: | ----: |\n" + - "| a | bb | ccc |", + Content: "| File | Description |\n" + + "| ---- | ----------- |\n" + + "| `manager_test.go` | Tests for context manager |", }) - rendered := RenderMessages(state, "", 100) + rendered := RenderMessages(state, "", 38) plain := testANSIPattern.ReplaceAllString(rendered, "") - for _, want := range []string{"left", "center", "right", "a", "bb", "ccc"} { - if !strings.Contains(plain, want) { - t.Fatalf("expected %q in rendered output, got:\n%s", want, plain) - } + 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, ":----:") { - t.Fatalf("expected alignment separator row to be hidden, got:\n%s", plain) + if !strings.Contains(plain, "manager") { + t.Fatalf("expected file name content to remain visible, got:\n%s", plain) } } diff --git a/ui/panels/markdown.go b/ui/panels/markdown.go index a104a62..fe43e85 100644 --- a/ui/panels/markdown.go +++ b/ui/panels/markdown.go @@ -365,6 +365,10 @@ func wrapTableText(text string, width int) []string { 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] @@ -382,6 +386,10 @@ func wrapTableText(text string, width int) []string { 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] @@ -395,6 +403,33 @@ func wrapTableText(text string, width int) []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{""} From b79ed211e3b27a00912dfd8e468075485de37299 Mon Sep 17 00:00:00 2001 From: GUOGUO <55723162+Dong1017@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:21:40 +0800 Subject: [PATCH 6/6] fix: wrap markdown content to viewport width Align chat markdown rendering more closely with Claude Code by wrapping paragraphs, lists, quotes, rules, and code blocks to the available width instead of truncating or using fixed widths. Co-Authored-By: Claude Sonnet 4.6 --- ui/panels/chat_test.go | 53 +++++++++++++++++++++++++++++++++++++ ui/panels/markdown.go | 60 +++++++++++++++++++++++++++++++++++------- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/ui/panels/chat_test.go b/ui/panels/chat_test.go index efafec5..f45a20a 100644 --- a/ui/panels/chat_test.go +++ b/ui/panels/chat_test.go @@ -283,3 +283,56 @@ func TestRenderMessagesRendersTableInlineCodeWithoutBreakingCodeSpan(t *testing. 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 index fe43e85..fa67e3c 100644 --- a/ui/panels/markdown.go +++ b/ui/panels/markdown.go @@ -103,7 +103,7 @@ func renderMarkdown(content string, width int) string { continue } if inCodeBlock { - rendered = append(rendered, renderCodeBlockLine(line, width)) + rendered = append(rendered, renderCodeBlockLine(line, width)...) continue } if trimmed == "" { @@ -120,26 +120,26 @@ func renderMarkdown(content string, width int) string { continue } if quote, ok := markdownQuote(line); ok { - rendered = append(rendered, mdQuoteStyle.Render("│ "+renderInlineMarkdown(quote))) + rendered = append(rendered, renderWrappedMarkdownWithPrefix(quote, width, "│ ", "│ ")...) continue } if indent, checked, item, ok := markdownTaskItem(line); ok { - rendered = append(rendered, renderListPrefix(indent, taskListMarker(checked))+renderInlineMarkdown(item)) + 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, renderListPrefix(indent, "• ")+renderInlineMarkdown(item)) + rendered = append(rendered, renderWrappedMarkdownWithPrefix(item, width, renderListPrefix(indent, "• "), renderListContinuationPrefix(indent, "• "))...) continue } if indent, index, item, ok := markdownOrderedItem(line); ok { - rendered = append(rendered, renderListPrefix(indent, index+". ")+renderInlineMarkdown(item)) + rendered = append(rendered, renderWrappedMarkdownWithPrefix(item, width, renderListPrefix(indent, index+". "), renderListContinuationPrefix(indent, index+". "))...) continue } if markdownRule(trimmed) { - rendered = append(rendered, mdRuleStyle.Render("────────────────────")) + rendered = append(rendered, renderMarkdownRule(width)) continue } - rendered = append(rendered, renderInlineMarkdown(line)) + rendered = append(rendered, wrapRenderedLine(renderInlineMarkdown(line), width)...) } return strings.Join(rendered, "\n") @@ -515,12 +515,20 @@ func truncatePlainText(text string, width int) string { return parts[0] } -func renderCodeBlockLine(line string, width int) string { +func renderCodeBlockLine(line string, width int) []string { contentWidth := width - codeBlockDecorationWidth() if contentWidth < 1 { contentWidth = 1 } - return mdCodeBlockStyle.Render(truncatePlainText(line, contentWidth)) + 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 { @@ -754,6 +762,40 @@ 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] "