1package chat
2
3import (
4 "fmt"
5 "strings"
6
7 "charm.land/bubbles/v2/key"
8 tea "charm.land/bubbletea/v2"
9 "charm.land/lipgloss/v2"
10 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13 "github.com/charmbracelet/x/ansi"
14)
15
16const maxCollapsedThinkingHeight = 10
17
18// AssistantMessageItem represents an assistant message that can be displayed
19// in the chat UI.
20type AssistantMessageItem struct {
21 id string
22 content string
23 thinking string
24 finished bool
25 finish message.Finish
26 sty *styles.Styles
27 thinkingExpanded bool
28 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
29}
30
31// NewAssistantMessage creates a new assistant message item.
32func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, sty *styles.Styles) *AssistantMessageItem {
33 return &AssistantMessageItem{
34 id: id,
35 content: content,
36 thinking: thinking,
37 finished: finished,
38 finish: finish,
39 sty: sty,
40 }
41}
42
43// ID implements Identifiable.
44func (m *AssistantMessageItem) ID() string {
45 return m.id
46}
47
48// FocusStyle returns the focus style.
49func (m *AssistantMessageItem) FocusStyle() lipgloss.Style {
50 return m.sty.Chat.Message.AssistantFocused
51}
52
53// BlurStyle returns the blur style.
54func (m *AssistantMessageItem) BlurStyle() lipgloss.Style {
55 return m.sty.Chat.Message.AssistantBlurred
56}
57
58// HighlightStyle returns the highlight style.
59func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style {
60 return m.sty.TextSelection
61}
62
63// Render implements list.Item.
64func (m *AssistantMessageItem) Render(width int) string {
65 cappedWidth := min(width, maxTextWidth)
66 content := strings.TrimSpace(m.content)
67 thinking := strings.TrimSpace(m.thinking)
68
69 // Handle empty finished messages.
70 if m.finished && content == "" {
71 switch m.finish.Reason {
72 case message.FinishReasonEndTurn:
73 return ""
74 case message.FinishReasonCanceled:
75 return m.renderMarkdown("*Canceled*", cappedWidth)
76 case message.FinishReasonError:
77 return m.renderError(cappedWidth)
78 }
79 }
80
81 var parts []string
82
83 // Render thinking content if present.
84 if thinking != "" {
85 parts = append(parts, m.renderThinking(thinking, cappedWidth))
86 }
87
88 // Render main content.
89 if content != "" {
90 if len(parts) > 0 {
91 parts = append(parts, "")
92 }
93 parts = append(parts, m.renderMarkdown(content, cappedWidth))
94 }
95
96 return lipgloss.JoinVertical(lipgloss.Left, parts...)
97}
98
99// renderMarkdown renders content as markdown.
100func (m *AssistantMessageItem) renderMarkdown(content string, width int) string {
101 renderer := common.MarkdownRenderer(m.sty, width)
102 result, err := renderer.Render(content)
103 if err != nil {
104 return content
105 }
106 return strings.TrimSuffix(result, "\n")
107}
108
109// renderThinking renders the thinking/reasoning content.
110func (m *AssistantMessageItem) renderThinking(thinking string, width int) string {
111 renderer := common.PlainMarkdownRenderer(m.sty, width-2)
112 rendered, err := renderer.Render(thinking)
113 if err != nil {
114 rendered = thinking
115 }
116 rendered = strings.TrimSpace(rendered)
117
118 lines := strings.Split(rendered, "\n")
119 totalLines := len(lines)
120
121 // Collapse if not expanded and exceeds max height.
122 isTruncated := totalLines > maxCollapsedThinkingHeight
123 if !m.thinkingExpanded && isTruncated {
124 lines = lines[totalLines-maxCollapsedThinkingHeight:]
125 }
126
127 // Add hint if truncated and not expanded.
128 if !m.thinkingExpanded && isTruncated {
129 hint := m.sty.Muted.Render(fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight))
130 lines = append([]string{hint}, lines...)
131 }
132
133 thinkingStyle := m.sty.Subtle.Background(m.sty.BgBaseLighter).Width(width)
134 result := thinkingStyle.Render(strings.Join(lines, "\n"))
135
136 // Track the rendered height for click detection.
137 m.thinkingBoxHeight = lipgloss.Height(result)
138
139 return result
140}
141
142// HandleMouseClick implements list.MouseClickable.
143func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
144 // Only handle left clicks.
145 if btn != ansi.MouseLeft {
146 return false
147 }
148
149 // Check if click is within the thinking box area.
150 if m.thinking != "" && y < m.thinkingBoxHeight {
151 m.thinkingExpanded = !m.thinkingExpanded
152 return true
153 }
154
155 return false
156}
157
158// HandleKeyPress implements list.KeyPressable.
159func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
160 // Only handle space key on thinking content.
161 if m.thinking == "" {
162 return false
163 }
164
165 if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
166 // Toggle thinking expansion.
167 m.thinkingExpanded = !m.thinkingExpanded
168 return true
169 }
170
171 return false
172}
173
174// renderError renders an error message.
175func (m *AssistantMessageItem) renderError(width int) string {
176 errTag := m.sty.Chat.Message.ErrorTag.Render("ERROR")
177 truncated := ansi.Truncate(m.finish.Message, width-2-lipgloss.Width(errTag), "...")
178 title := fmt.Sprintf("%s %s", errTag, m.sty.Chat.Message.ErrorTitle.Render(truncated))
179 details := m.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(m.finish.Details)
180 return fmt.Sprintf("%s\n\n%s", title, details)
181}