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
38var _ Expandable = (*AssistantMessageItem)(nil)
39
40// NewAssistantMessageItem creates a new AssistantMessageItem.
41func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
42 a := &AssistantMessageItem{
43 highlightableMessageItem: defaultHighlighter(sty),
44 cachedMessageItem: &cachedMessageItem{},
45 focusableMessageItem: &focusableMessageItem{},
46 message: message,
47 sty: sty,
48 }
49
50 a.anim = anim.New(anim.Settings{
51 ID: a.ID(),
52 Size: 15,
53 GradColorA: sty.WorkingGradFromColor,
54 GradColorB: sty.WorkingGradToColor,
55 LabelColor: sty.WorkingLabelColor,
56 CycleColors: true,
57 })
58 return a
59}
60
61// StartAnimation starts the assistant message animation if it should be spinning.
62func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
63 if !a.isSpinning() {
64 return nil
65 }
66 return a.anim.Start()
67}
68
69// Animate progresses the assistant message animation if it should be spinning.
70func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
71 if !a.isSpinning() {
72 return nil
73 }
74 return a.anim.Animate(msg)
75}
76
77// ID implements MessageItem.
78func (a *AssistantMessageItem) ID() string {
79 return a.message.ID
80}
81
82// RawRender implements [MessageItem].
83func (a *AssistantMessageItem) RawRender(width int) string {
84 cappedWidth := cappedMessageWidth(width)
85
86 var spinner string
87 if a.isSpinning() {
88 spinner = a.renderSpinning()
89 }
90
91 content, height, ok := a.getCachedRender(cappedWidth)
92 if !ok {
93 content = a.renderMessageContent(cappedWidth)
94 height = lipgloss.Height(content)
95 // cache the rendered content
96 a.setCachedRender(content, cappedWidth, height)
97 }
98
99 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
100 if spinner != "" {
101 if highlightedContent != "" {
102 highlightedContent += "\n\n"
103 }
104 return highlightedContent + spinner
105 }
106
107 return highlightedContent
108}
109
110// Render implements MessageItem.
111func (a *AssistantMessageItem) Render(width int) string {
112 // XXX: Here, we're manually applying the focused/blurred styles because
113 // using lipgloss.Render can degrade performance for long messages due to
114 // it's wrapping logic.
115 // We already know that the content is wrapped to the correct width in
116 // RawRender, so we can just apply the styles directly to each line.
117 //
118 // The split + per-line prefix loop is O(L); cache the result keyed
119 // by (width, focused) so steady-state Render becomes a pointer
120 // return. Bypass the cache while spinning (RawRender's spinner
121 // suffix changes every animation frame) or while a highlight range
122 // is active (selection drag).
123 useCache := !a.isSpinning() && !a.isHighlighted()
124 var key uint64
125 if a.focused {
126 key = 1
127 }
128 if useCache {
129 if cached, ok := a.getCachedPrefixedRender(width, key); ok {
130 return cached
131 }
132 }
133 focused := a.sty.Messages.AssistantFocused.Render()
134 blurred := a.sty.Messages.AssistantBlurred.Render()
135 rendered := a.RawRender(width)
136 lines := strings.Split(rendered, "\n")
137 for i, line := range lines {
138 if a.focused {
139 lines[i] = focused + line
140 } else {
141 lines[i] = blurred + line
142 }
143 }
144 out := strings.Join(lines, "\n")
145 if useCache {
146 a.setCachedPrefixedRender(out, width, key)
147 }
148 return out
149}
150
151// renderMessageContent renders the message content including thinking, main content, and finish reason.
152func (a *AssistantMessageItem) renderMessageContent(width int) string {
153 var messageParts []string
154 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
155 content := strings.TrimSpace(a.message.Content().Text)
156 // if the massage has reasoning content add that first
157 if thinking != "" {
158 messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
159 }
160
161 // then add the main content
162 if content != "" {
163 // add a spacer between thinking and content
164 if thinking != "" {
165 messageParts = append(messageParts, "")
166 }
167 messageParts = append(messageParts, a.renderMarkdown(content, width))
168 }
169
170 // finally add any finish reason info
171 if a.message.IsFinished() {
172 switch a.message.FinishReason() {
173 case message.FinishReasonCanceled:
174 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
175 case message.FinishReasonError:
176 messageParts = append(messageParts, a.renderError(width))
177 }
178 }
179
180 return strings.Join(messageParts, "\n")
181}
182
183// renderThinking renders the thinking/reasoning content with footer.
184func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
185 renderer := common.QuietMarkdownRenderer(a.sty, width)
186 rendered, err := renderer.Render(thinking)
187 if err != nil {
188 rendered = thinking
189 }
190 rendered = strings.TrimSpace(rendered)
191
192 lines := strings.Split(rendered, "\n")
193 totalLines := len(lines)
194
195 isTruncated := totalLines > maxCollapsedThinkingHeight
196 if !a.thinkingExpanded && isTruncated {
197 lines = lines[totalLines-maxCollapsedThinkingHeight:]
198 hint := a.sty.Messages.ThinkingTruncationHint.Render(
199 fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
200 )
201 lines = append([]string{hint, ""}, lines...)
202 }
203
204 thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
205 result := thinkingStyle.Render(strings.Join(lines, "\n"))
206 a.thinkingBoxHeight = lipgloss.Height(result)
207
208 var footer string
209 // if thinking is done add the thought for footer
210 if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
211 duration := a.message.ThinkingDuration()
212 if duration.String() != "0s" {
213 footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
214 a.sty.Messages.ThinkingFooterDuration.Render(duration.String())
215 }
216 }
217
218 if footer != "" {
219 result += "\n\n" + footer
220 }
221
222 return result
223}
224
225// renderMarkdown renders content as markdown.
226func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
227 renderer := common.MarkdownRenderer(a.sty, width)
228 result, err := renderer.Render(content)
229 if err != nil {
230 return content
231 }
232 return strings.TrimSuffix(result, "\n")
233}
234
235func (a *AssistantMessageItem) renderSpinning() string {
236 if a.message.IsThinking() {
237 a.anim.SetLabel("Thinking")
238 } else if a.message.IsSummaryMessage {
239 a.anim.SetLabel("Summarizing")
240 }
241 return a.anim.Render()
242}
243
244// renderError renders an error message.
245func (a *AssistantMessageItem) renderError(width int) string {
246 finishPart := a.message.FinishPart()
247 errTag := a.sty.Messages.ErrorTag.Render("ERROR")
248 truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
249 title := fmt.Sprintf("%s %s", errTag, a.sty.Messages.ErrorTitle.Render(truncated))
250 details := a.sty.Messages.ErrorDetails.Width(width - 2).Render(finishPart.Details)
251 return fmt.Sprintf("%s\n\n%s", title, details)
252}
253
254// isSpinning returns true if the assistant message is still generating.
255func (a *AssistantMessageItem) isSpinning() bool {
256 isThinking := a.message.IsThinking()
257 isFinished := a.message.IsFinished()
258 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
259 hasToolCalls := len(a.message.ToolCalls()) > 0
260 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
261}
262
263// SetMessage is used to update the underlying message.
264func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
265 wasSpinning := a.isSpinning()
266 a.message = message
267 a.clearCache()
268 if !wasSpinning && a.isSpinning() {
269 return a.StartAnimation()
270 }
271 return nil
272}
273
274// ToggleExpanded toggles the expanded state of the thinking box and returns
275// whether the item is now expanded.
276func (a *AssistantMessageItem) ToggleExpanded() bool {
277 a.thinkingExpanded = !a.thinkingExpanded
278 a.clearCache()
279 return a.thinkingExpanded
280}
281
282// HandleMouseClick implements MouseClickable. It signals (via a true return)
283// that the click lies on the thinking box so the caller can invoke
284// [AssistantMessageItem.ToggleExpanded] through the generic [Expandable]
285// path. Toggling here directly would double-toggle because the caller always
286// runs the generic path after a handled click.
287func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
288 if btn != ansi.MouseLeft {
289 return false
290 }
291 // Only the thinking box is clickable; other regions of the assistant
292 // message should not trigger expansion.
293 return a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight
294}
295
296// HandleKeyEvent implements KeyEventHandler.
297func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
298 if k := key.String(); k == "c" || k == "y" {
299 text := a.message.Content().Text
300 return true, common.CopyToClipboard(text, "Message copied to clipboard")
301 }
302 return false, nil
303}