Skip to content

Commit 43b3f54

Browse files
authored
fix: improve TUI markdown rendering (#183)
1 parent c11f8ce commit 43b3f54

5 files changed

Lines changed: 101 additions & 20 deletions

File tree

internal/agent/markdown_render.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package agent
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/Use-Tusk/tusk-drift-cli/internal/utils"
8+
)
9+
10+
var (
11+
gitScpLikeRemotePattern = regexp.MustCompile(`\b[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s)>\]]+`)
12+
emailPatternWithDelimiter = regexp.MustCompile(`\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})(\s|$|[),>.\]!?\]])`)
13+
)
14+
15+
// renderAgentMessage renders agent output as markdown with terminal styling.
16+
// Falls back to wrapped plain text when markdown rendering isn't available.
17+
func renderAgentMessage(text string, width int) string {
18+
if width <= 0 {
19+
width = 80
20+
}
21+
22+
originalText := text
23+
24+
// Prevent markdown autolink/email parsers from converting scp-style Git remotes
25+
// (e.g., git@github.com:org/repo.git) into broken mailto links.
26+
text = gitScpLikeRemotePattern.ReplaceAllStringFunc(text, func(match string) string {
27+
return "`" + match + "`"
28+
})
29+
text = emailPatternWithDelimiter.ReplaceAllString(text, "`$1`$2")
30+
31+
rendered := utils.RenderMarkdownWithWidth(text, width)
32+
rendered = strings.TrimRight(rendered, "\n")
33+
34+
// RenderMarkdownWithWidth returns raw text in non-terminal/no-color mode.
35+
// Keep output readable by wrapping plain text to the viewport width.
36+
if rendered == text {
37+
return utils.WrapText(originalText, width)
38+
}
39+
40+
return rendered
41+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package agent
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestRenderAgentMessage_DoesNotCreateMailtoForGitRemote(t *testing.T) {
9+
input := "Remote: origin (git@github.com:Use-Tusk/tusk-drift-cli.git)"
10+
11+
out := renderAgentMessage(input, 100)
12+
13+
if strings.Contains(out, "mailto:") {
14+
t.Fatalf("expected no mailto link in rendered output, got: %q", out)
15+
}
16+
}
17+
18+
func TestRenderAgentMessage_DoesNotCreateMailtoForPlainEmail(t *testing.T) {
19+
input := "Authenticated as jy@usetusk.ai. Continuing setup..."
20+
21+
out := renderAgentMessage(input, 100)
22+
23+
if strings.Contains(out, "mailto:") {
24+
t.Fatalf("expected no mailto link in rendered output, got: %q", out)
25+
}
26+
}
27+
28+
func TestRenderAgentMessage_FallbackDoesNotLeakSanitizerBackticks(t *testing.T) {
29+
input := "Remote: git@github.com:Use-Tusk/tusk-drift-cli.git | Email: jy@usetusk.ai"
30+
31+
out := renderAgentMessage(input, 100)
32+
33+
if strings.Contains(out, "`git@github.com:Use-Tusk/tusk-drift-cli.git`") ||
34+
strings.Contains(out, "`jy@usetusk.ai`") {
35+
t.Fatalf("expected fallback/rendered output to not contain sanitizer backticks, got: %q", out)
36+
}
37+
}

internal/agent/tui.go

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -534,28 +534,14 @@ func (m *TUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
534534
if msg.text != "" && msg.text != m.lastAgentMessage {
535535
m.lastAgentMessage = msg.text
536536
m.addLog("spacing", "", "")
537-
// Format agent messages with proper markdown spacing
538-
// Wrap text at 120 chars max for readability
539537
maxWidth := min(max(m.width-8, 40), 120)
540-
lines := strings.Split(msg.text, "\n")
541-
for i, line := range lines {
542-
trimmed := strings.TrimSpace(line)
543-
switch {
544-
case trimmed == "":
545-
// Keep blank lines for markdown formatting
538+
rendered := renderAgentMessage(msg.text, maxWidth)
539+
for _, line := range strings.Split(rendered, "\n") {
540+
if strings.TrimSpace(line) == "" {
546541
m.addLog("spacing", "", "")
547-
case strings.HasPrefix(trimmed, "#"):
548-
if i > 0 {
549-
m.addLog("spacing", "", "")
550-
}
551-
m.addLog("agent-header", trimmed, "")
552-
default:
553-
// Wrap long lines for readability
554-
wrapped := wrapText(trimmed, maxWidth)
555-
for _, wrappedLine := range strings.Split(wrapped, "\n") {
556-
m.addLog("agent", wrappedLine, "")
557-
}
542+
continue
558543
}
544+
m.addLog("agent", line, "")
559545
}
560546
}
561547
}

internal/agent/ui_headless.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import (
66
"os"
77
"strings"
88

9+
"golang.org/x/term"
10+
11+
"github.com/Use-Tusk/tusk-drift-cli/internal/utils"
912
"github.com/charmbracelet/lipgloss"
1013
)
1114

@@ -97,7 +100,16 @@ func (u *HeadlessUI) AgentText(text string, streaming bool) {
97100
u.isThinking = false
98101
}
99102
if strings.TrimSpace(text) != "" {
100-
fmt.Println(text)
103+
width := 90
104+
if utils.IsTerminal() {
105+
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
106+
width = max(w-4, 40)
107+
}
108+
}
109+
110+
// Headless mode intentionally keeps raw markdown syntax and avoids
111+
// glamour rendering for lower overhead and predictable plain output.
112+
fmt.Println(utils.WrapText(text, width))
101113
fmt.Println()
102114
}
103115
}

internal/utils/markdown.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ func init() {
3535
"margin": 0,
3636
}
3737

38+
styleOverrides["link"] = map[string]any{
39+
"color": styles.PrimaryColor,
40+
"underline": true,
41+
}
42+
3843
if hasDarkBackground {
3944
styleOverrides["document"].(map[string]any)["color"] = "255"
4045
styleOverrides["heading"] = map[string]any{

0 commit comments

Comments
 (0)