Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ evals
/cagent
.crush
.vscode
.idea/
*.debug

# agents
Expand Down
8 changes: 4 additions & 4 deletions examples/welcome_message.yaml
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions pkg/tui/components/markdown/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func NewGlamourRenderer(width int) *glamour.TermRenderer {
r, _ := glamour.NewTermRenderer(
glamour.WithWordWrap(width),
glamour.WithStyles(style),
glamour.WithPreservedNewLines(),
)
return r
}
41 changes: 40 additions & 1 deletion pkg/tui/components/message/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error fallback uses original content instead of processed content

The preserveLineBreaks() function is called on line 151 and stores the result in the content variable (leading spaces converted to non-breaking spaces). However, when markdown rendering fails, line 153 falls back to msg.Content instead of content, which loses the indentation processing.

Suggested change
// allowing markdown formatting like **bold** and *italic*
rendered = content

This ensures consistent behavior where indentation is preserved regardless of whether markdown rendering succeeds or fails. While rendering errors should be rare, they can occur and would currently cause unexpected loss of indentation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line numbers of this suggestion are off a bit. I'll take a look at fixing it today.

content := preserveLineBreaks(msg.Content)
rendered, err := markdown.NewRenderer(width - messageStyle.GetHorizontalFrameSize()).Render(content)
if err != nil {
rendered = msg.Content
}
Expand Down Expand Up @@ -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:]
}
129 changes: 129 additions & 0 deletions pkg/tui/components/message/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}