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// assistantMessageTruncateFormat is the text shown when an assistant message is
17// truncated.
18const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
19
20// maxCollapsedThinkingHeight defines the maximum height of the thinking
21const maxCollapsedThinkingHeight = 10
22
23// AssistantMessageItem represents an assistant message in the chat UI.
24//
25// This item includes thinking, and the content but does not include the tool calls.
26type AssistantMessageItem struct {
27 *highlightableMessageItem
28 *cachedMessageItem
29 *focusableMessageItem
30
31 message *message.Message
32 sty *styles.Styles
33 anim *anim.Anim
34 thinkingExpanded bool
35 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
36}
37
38// NewAssistantMessageItem creates a new AssistantMessageItem.
39func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
40 a := &AssistantMessageItem{
41 highlightableMessageItem: defaultHighlighter(sty),
42 cachedMessageItem: &cachedMessageItem{},
43 focusableMessageItem: &focusableMessageItem{},
44 message: message,
45 sty: sty,
46 }
47
48 a.anim = anim.New(anim.Settings{
49 ID: a.ID(),
50 Size: 15,
51 GradColorA: sty.Primary,
52 GradColorB: sty.Secondary,
53 LabelColor: sty.FgBase,
54 CycleColors: true,
55 })
56 return a
57}
58
59// StartAnimation starts the assistant message animation if it should be spinning.
60func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
61 if !a.isSpinning() {
62 return nil
63 }
64 return a.anim.Start()
65}
66
67// Animate progresses the assistant message animation if it should be spinning.
68func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
69 if !a.isSpinning() {
70 return nil
71 }
72 return a.anim.Animate(msg)
73}
74
75// ID implements MessageItem.
76func (a *AssistantMessageItem) ID() string {
77 return a.message.ID
78}
79
80// RawRender implements [MessageItem].
81func (a *AssistantMessageItem) RawRender(width int) string {
82 cappedWidth := cappedMessageWidth(width)
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 highlightedContent + spinner
103 }
104
105 return highlightedContent
106}
107
108// Render implements MessageItem.
109func (a *AssistantMessageItem) Render(width int) string {
110 // XXX: Here, we're manually applying the focused/blurred styles because
111 // using lipgloss.Render can degrade performance for long messages due to
112 // it's wrapping logic.
113 // We already know that the content is wrapped to the correct width in
114 // RawRender, so we can just apply the styles directly to each line.
115 focused := a.sty.Chat.Message.AssistantFocused.Render()
116 blurred := a.sty.Chat.Message.AssistantBlurred.Render()
117 rendered := a.RawRender(width)
118 lines := strings.Split(rendered, "\n")
119 for i, line := range lines {
120 if a.focused {
121 lines[i] = focused + line
122 } else {
123 lines[i] = blurred + line
124 }
125 }
126 return strings.Join(lines, "\n")
127}
128
129// renderMessageContent renders the message content including thinking, main content, and finish reason.
130func (a *AssistantMessageItem) renderMessageContent(width int) string {
131 var messageParts []string
132 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
133 content := strings.TrimSpace(a.message.Content().Text)
134 // if the massage has reasoning content add that first
135 if thinking != "" {
136 messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
137 }
138
139 // then add the main content
140 if content != "" {
141 // add a spacer between thinking and content
142 if thinking != "" {
143 messageParts = append(messageParts, "")
144 }
145 messageParts = append(messageParts, a.renderMarkdown(content, width))
146 }
147
148 // finally add any finish reason info
149 if a.message.IsFinished() {
150 switch a.message.FinishReason() {
151 case message.FinishReasonCanceled:
152 messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
153 case message.FinishReasonError:
154 messageParts = append(messageParts, a.renderError(width))
155 }
156 }
157
158 return strings.Join(messageParts, "\n")
159}
160
161// renderThinking renders the thinking/reasoning content with footer.
162func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
163 renderer := common.PlainMarkdownRenderer(a.sty, width)
164 rendered, err := renderer.Render(thinking)
165 if err != nil {
166 rendered = thinking
167 }
168 rendered = strings.TrimSpace(rendered)
169
170 lines := strings.Split(rendered, "\n")
171 totalLines := len(lines)
172
173 isTruncated := totalLines > maxCollapsedThinkingHeight
174 if !a.thinkingExpanded && isTruncated {
175 lines = lines[totalLines-maxCollapsedThinkingHeight:]
176 hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
177 fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
178 )
179 lines = append([]string{hint, ""}, lines...)
180 }
181
182 thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
183 result := thinkingStyle.Render(strings.Join(lines, "\n"))
184 a.thinkingBoxHeight = lipgloss.Height(result)
185
186 var footer string
187 // if thinking is done add the thought for footer
188 if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
189 duration := a.message.ThinkingDuration()
190 if duration.String() != "0s" {
191 footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
192 a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
193 }
194 }
195
196 if footer != "" {
197 result += "\n\n" + footer
198 }
199
200 return result
201}
202
203// renderMarkdown renders content as markdown.
204func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
205 renderer := common.MarkdownRenderer(a.sty, width)
206 result, err := renderer.Render(content)
207 if err != nil {
208 return content
209 }
210 return strings.TrimSuffix(result, "\n")
211}
212
213func (a *AssistantMessageItem) renderSpinning() string {
214 if a.message.IsThinking() {
215 a.anim.SetLabel("Thinking")
216 } else if a.message.IsSummaryMessage {
217 a.anim.SetLabel("Summarizing")
218 }
219 return a.anim.Render()
220}
221
222// renderError renders an error message.
223func (a *AssistantMessageItem) renderError(width int) string {
224 finishPart := a.message.FinishPart()
225 errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
226 truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
227 title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
228 details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
229 return fmt.Sprintf("%s\n\n%s", title, details)
230}
231
232// isSpinning returns true if the assistant message is still generating.
233func (a *AssistantMessageItem) isSpinning() bool {
234 isThinking := a.message.IsThinking()
235 isFinished := a.message.IsFinished()
236 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
237 hasToolCalls := len(a.message.ToolCalls()) > 0
238 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
239}
240
241// SetMessage is used to update the underlying message.
242func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
243 wasSpinning := a.isSpinning()
244 a.message = message
245 a.clearCache()
246 if !wasSpinning && a.isSpinning() {
247 return a.StartAnimation()
248 }
249 return nil
250}
251
252// ToggleExpanded toggles the expanded state of the thinking box.
253func (a *AssistantMessageItem) ToggleExpanded() {
254 a.thinkingExpanded = !a.thinkingExpanded
255 a.clearCache()
256}
257
258// HandleMouseClick implements MouseClickable.
259func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
260 if btn != ansi.MouseLeft {
261 return false
262 }
263 // check if the click is within the thinking box
264 if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
265 a.ToggleExpanded()
266 return true
267 }
268 return false
269}
270
271// HandleKeyEvent implements KeyEventHandler.
272func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
273 if k := key.String(); k == "c" || k == "y" {
274 text := a.message.Content().Text
275 return true, common.CopyToClipboard(text, "Message copied to clipboard")
276 }
277 return false, nil
278}