diff --git a/.gitignore b/.gitignore index 96c6ce08d..88c030f26 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ evals /cagent .crush .vscode +.idea/ *.debug # agents diff --git a/examples/welcome_message.yaml b/examples/welcome_message.yaml old mode 100644 new mode 100755 index c920c68b3..a67b3815c --- a/examples/welcome_message.yaml +++ b/examples/welcome_message.yaml @@ -10,10 +10,10 @@ agents: I'm your AI assistant, ready to help you with various tasks. Here are some things I can do: - - Answer questions and provide information - - Help with coding and technical problems - - Assist with writing and content creation - - And much more! + - Answer questions and provide information + - Help with coding and technical problems + - Assist with writing and content creation + - And much more! Just type your question or request below to get started. instruction: | diff --git a/pkg/tui/components/markdown/renderer.go b/pkg/tui/components/markdown/renderer.go index 5de6e1f73..210336bfc 100644 --- a/pkg/tui/components/markdown/renderer.go +++ b/pkg/tui/components/markdown/renderer.go @@ -29,6 +29,7 @@ func NewGlamourRenderer(width int) *glamour.TermRenderer { r, _ := glamour.NewTermRenderer( glamour.WithWordWrap(width), glamour.WithStyles(style), + glamour.WithPreservedNewLines(), ) return r } diff --git a/pkg/tui/components/message/message.go b/pkg/tui/components/message/message.go index 728f56797..533816dc4 100644 --- a/pkg/tui/components/message/message.go +++ b/pkg/tui/components/message/message.go @@ -148,7 +148,11 @@ func (mv *messageModel) Render(width int) string { return styles.WarningStyle.Render("⚠ stream cancelled ⚠") case types.MessageTypeWelcome: messageStyle := styles.WelcomeMessageStyle - rendered, err := markdown.NewRenderer(width - messageStyle.GetHorizontalFrameSize()).Render(msg.Content) + // Convert explicit newlines to markdown hard line breaks (two trailing spaces) + // This preserves line breaks from YAML multiline syntax (|) while still + // allowing markdown formatting like **bold** and *italic* + content := preserveLineBreaks(msg.Content) + rendered, err := markdown.NewRenderer(width - messageStyle.GetHorizontalFrameSize()).Render(content) if err != nil { rendered = msg.Content } @@ -223,3 +227,38 @@ var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") } + +// preserveLineBreaks preserves leading indentation by converting leading spaces +// to non-breaking spaces (U+00A0) which won't be stripped by markdown parsers. +// Line breaks are handled by glamour.WithPreservedNewLines(). +func preserveLineBreaks(s string) string { + if !strings.Contains(s, "\n") { + return preserveIndentation(s) + } + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = preserveIndentation(line) + } + return strings.Join(lines, "\n") +} + +// preserveIndentation converts leading spaces in a line to non-breaking spaces (U+00A0). +// This prevents markdown parsers from stripping leading whitespace while maintaining +// the same visual appearance in terminal output. +func preserveIndentation(line string) string { + if line == "" || line[0] != ' ' { + return line + } + leadingSpaces := 0 + for _, c := range line { + if c == ' ' { + leadingSpaces++ + } else { + break + } + } + if leadingSpaces == 0 { + return line + } + return strings.Repeat("\u00A0", leadingSpaces) + line[leadingSpaces:] +} diff --git a/pkg/tui/components/message/message_test.go b/pkg/tui/components/message/message_test.go index 6e59bd27d..c27915a68 100644 --- a/pkg/tui/components/message/message_test.go +++ b/pkg/tui/components/message/message_test.go @@ -84,3 +84,132 @@ func TestErrorMessagePreservesContent(t *testing.T) { assert.Contains(t, plainRendered, "database") assert.Contains(t, plainRendered, "timeout") } + +func TestPreserveLineBreaks(t *testing.T) { + t.Parallel() + const nbsp = "\u00A0" + tests := []struct { + name string + input string + expected string + }{ + { + name: "single line unchanged", + input: "Hello world", + expected: "Hello world", + }, + { + name: "two lines preserved", + input: "Line one\nLine two", + expected: "Line one\nLine two", + }, + { + name: "empty line preserved", + input: "Para one\n\nPara two", + expected: "Para one\n\nPara two", + }, + { + name: "trailing newline preserved", + input: "Line one\n", + expected: "Line one\n", + }, + { + name: "multiple lines with indentation preserved as nbsp", + input: "Hello\n indented\nback", + expected: "Hello\n" + nbsp + nbsp + nbsp + "indented\nback", + }, + { + name: "single line with leading spaces", + input: " indented", + expected: nbsp + nbsp + "indented", + }, + { + name: "tabs are not converted", + input: "\tindented", + expected: "\tindented", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := preserveLineBreaks(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestPreserveIndentation(t *testing.T) { + t.Parallel() + const nbsp = "\u00A0" + tests := []struct { + name string + input string + expected string + }{ + { + name: "no indentation", + input: "hello", + expected: "hello", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "single leading space", + input: " hello", + expected: nbsp + "hello", + }, + { + name: "multiple leading spaces", + input: " hello", + expected: nbsp + nbsp + nbsp + "hello", + }, + { + name: "only spaces", + input: " ", + expected: nbsp + nbsp + nbsp, + }, + { + name: "spaces in middle not converted", + input: "hello world", + expected: "hello world", + }, + { + name: "leading spaces with spaces in middle", + input: " hello world", + expected: nbsp + nbsp + "hello world", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := preserveIndentation(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestWelcomeMessagePreservesLineBreaks(t *testing.T) { + t.Parallel() + + // Simulate YAML multiline content with | syntax + welcomeContent := "Welcome!\n indented line\nregular line" + msg := types.Welcome(welcomeContent) + mv := New(msg, nil) + + width := 80 + mv.SetSize(width, 0) + + rendered := mv.View() + require.NotEmpty(t, rendered) + + // The rendered output should have separate lines (hard breaks preserved) + lines := strings.Split(rendered, "\n") + assert.Greater(t, len(lines), 2, "Welcome message should preserve line breaks") + + // Verify indentation is preserved in the output + plainRendered := stripANSI(rendered) + assert.Contains(t, plainRendered, "indented") +}