Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions ui/panels/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand All @@ -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 {
Expand Down
258 changes: 258 additions & 0 deletions ui/panels/chat_test.go
Original file line number Diff line number Diff line change
@@ -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{
Expand Down Expand Up @@ -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 `<words>` and `inline` here.\n\n```txt\nnot a fence marker\n```",
})

rendered := RenderMessages(state, "", 80)
plain := testANSIPattern.ReplaceAllString(rendered, "")

for _, want := range []string{"<words>", "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)
}
}
Loading