1package chat
2
3import (
4 "fmt"
5 "strings"
6
7 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/message"
10 "github.com/charmbracelet/crush/internal/ui/anim"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13 "github.com/charmbracelet/x/ansi"
14)
15
16// maxCollapsedThinkingHeight defines the maximum height of the thinking
17const maxCollapsedThinkingHeight = 10
18
19// AssistantMessageItem represents an assistant message in the chat UI.
20//
21// This item includes thinking, and the content but does not include the tool calls.
22type AssistantMessageItem struct {
23 *highlightableMessageItem
24 *cachedMessageItem
25 *focusableMessageItem
26
27 message *message.Message
28 sty *styles.Styles
29 anim *anim.Anim
30 thinkingExpanded bool
31 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
32}
33
34// NewAssistantMessageItem creates a new AssistantMessageItem.
35func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
36 a := &AssistantMessageItem{
37 highlightableMessageItem: defaultHighlighter(sty),
38 cachedMessageItem: &cachedMessageItem{},
39 focusableMessageItem: &focusableMessageItem{},
40 message: message,
41 sty: sty,
42 }
43
44 a.anim = anim.New(anim.Settings{
45 ID: a.ID(),
46 Size: 15,
47 GradColorA: sty.Primary,
48 GradColorB: sty.Secondary,
49 LabelColor: sty.FgBase,
50 CycleColors: true,
51 })
52 return a
53}
54
55// StartAnimation starts the assistant message animation if it should be spinning.
56func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
57 if !a.isSpinning() {
58 return nil
59 }
60 return a.anim.Start()
61}
62
63// Animate progresses the assistant message animation if it should be spinning.
64func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
65 if !a.isSpinning() {
66 return nil
67 }
68 return a.anim.Animate(msg)
69}
70
71// ID implements MessageItem.
72func (a *AssistantMessageItem) ID() string {
73 return a.message.ID
74}
75
76// Render implements MessageItem.
77func (a *AssistantMessageItem) Render(width int) string {
78 cappedWidth := cappedMessageWidth(width)
79 style := a.sty.Chat.Message.AssistantBlurred
80 if a.focused {
81 style = a.sty.Chat.Message.AssistantFocused
82 }
83
84 var spinner string
85 if a.isSpinning() {
86 spinner = a.renderSpinning()
87 }
88
89 content, height, ok := a.getCachedRender(cappedWidth)
90 if !ok {
91 content = a.renderMessageContent(cappedWidth)
92 height = lipgloss.Height(content)
93 // cache the rendered content
94 a.setCachedRender(content, cappedWidth, height)
95 }
96
97 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
98 if spinner != "" {
99 if highlightedContent != "" {
100 highlightedContent += "\n\n"
101 }
102 return style.Render(highlightedContent + spinner)
103 }
104
105 return style.Render(highlightedContent)
106}
107
108// renderMessageContent renders the message content including thinking, main content, and finish reason.
109func (a *AssistantMessageItem) renderMessageContent(width int) string {
110 var messageParts []string
111 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
112 content := strings.TrimSpace(a.message.Content().Text)
113 // if the massage has reasoning content add that first
114 if thinking != "" {
115 messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
116 }
117
118 // then add the main content
119 if content != "" {
120 // add a spacer between thinking and content
121 if thinking != "" {
122 messageParts = append(messageParts, "")
123 }
124 messageParts = append(messageParts, a.renderMarkdown(content, width))
125 }
126
127 // finally add any finish reason info
128 if a.message.IsFinished() {
129 switch a.message.FinishReason() {
130 case message.FinishReasonCanceled:
131 messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
132 case message.FinishReasonError:
133 messageParts = append(messageParts, a.renderError(width))
134 }
135 }
136
137 return strings.Join(messageParts, "\n")
138}
139
140// renderThinking renders the thinking/reasoning content with footer.
141func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
142 renderer := common.PlainMarkdownRenderer(a.sty, width)
143 rendered, err := renderer.Render(thinking)
144 if err != nil {
145 rendered = thinking
146 }
147 rendered = strings.TrimSpace(rendered)
148
149 lines := strings.Split(rendered, "\n")
150 totalLines := len(lines)
151
152 isTruncated := totalLines > maxCollapsedThinkingHeight
153 if !a.thinkingExpanded && isTruncated {
154 lines = lines[totalLines-maxCollapsedThinkingHeight:]
155 hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
156 fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
157 )
158 lines = append(lines, "", hint)
159 }
160
161 thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
162 result := thinkingStyle.Render(strings.Join(lines, "\n"))
163 a.thinkingBoxHeight = lipgloss.Height(result)
164
165 var footer string
166 // if thinking is done add the thought for footer
167 if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
168 duration := a.message.ThinkingDuration()
169 if duration.String() != "0s" {
170 footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
171 a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
172 }
173 }
174
175 if footer != "" {
176 result += "\n\n" + footer
177 }
178
179 return result
180}
181
182// renderMarkdown renders content as markdown.
183func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
184 renderer := common.MarkdownRenderer(a.sty, width)
185 result, err := renderer.Render(content)
186 if err != nil {
187 return content
188 }
189 return strings.TrimSuffix(result, "\n")
190}
191
192func (a *AssistantMessageItem) renderSpinning() string {
193 if a.message.IsThinking() {
194 a.anim.SetLabel("Thinking")
195 } else if a.message.IsSummaryMessage {
196 a.anim.SetLabel("Summarizing")
197 }
198 return a.anim.Render()
199}
200
201// renderError renders an error message.
202func (a *AssistantMessageItem) renderError(width int) string {
203 finishPart := a.message.FinishPart()
204 errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
205 truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
206 title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
207 details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
208 return fmt.Sprintf("%s\n\n%s", title, details)
209}
210
211// isSpinning returns true if the assistant message is still generating.
212func (a *AssistantMessageItem) isSpinning() bool {
213 isThinking := a.message.IsThinking()
214 isFinished := a.message.IsFinished()
215 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
216 hasToolCalls := len(a.message.ToolCalls()) > 0
217 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
218}
219
220// SetMessage is used to update the underlying message.
221func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
222 wasSpinning := a.isSpinning()
223 a.message = message
224 a.clearCache()
225 if !wasSpinning && a.isSpinning() {
226 return a.StartAnimation()
227 }
228 return nil
229}
230
231// ToggleExpanded toggles the expanded state of the thinking box.
232func (a *AssistantMessageItem) ToggleExpanded() {
233 a.thinkingExpanded = !a.thinkingExpanded
234 a.clearCache()
235}
236
237// HandleMouseClick implements MouseClickable.
238func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
239 if btn != ansi.MouseLeft {
240 return false
241 }
242 // check if the click is within the thinking box
243 if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
244 a.ToggleExpanded()
245 return true
246 }
247 return false
248}