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 focused := a.sty.Messages.AssistantFocused.Render()
118 blurred := a.sty.Messages.AssistantBlurred.Render()
119 rendered := a.RawRender(width)
120 lines := strings.Split(rendered, "\n")
121 for i, line := range lines {
122 if a.focused {
123 lines[i] = focused + line
124 } else {
125 lines[i] = blurred + line
126 }
127 }
128 return strings.Join(lines, "\n")
129}
130
131// renderMessageContent renders the message content including thinking, main content, and finish reason.
132func (a *AssistantMessageItem) renderMessageContent(width int) string {
133 var messageParts []string
134 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
135 content := strings.TrimSpace(a.message.Content().Text)
136 // if the massage has reasoning content add that first
137 if thinking != "" {
138 messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
139 }
140
141 // then add the main content
142 if content != "" {
143 // add a spacer between thinking and content
144 if thinking != "" {
145 messageParts = append(messageParts, "")
146 }
147 messageParts = append(messageParts, a.renderMarkdown(content, width))
148 }
149
150 // finally add any finish reason info
151 if a.message.IsFinished() {
152 switch a.message.FinishReason() {
153 case message.FinishReasonCanceled:
154 messageParts = append(messageParts, a.sty.Messages.AssistantCanceled.Render("Canceled"))
155 case message.FinishReasonError:
156 messageParts = append(messageParts, a.renderError(width))
157 }
158 }
159
160 return strings.Join(messageParts, "\n")
161}
162
163// renderThinking renders the thinking/reasoning content with footer.
164func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
165 renderer := common.QuietMarkdownRenderer(a.sty, width)
166 rendered, err := renderer.Render(thinking)
167 if err != nil {
168 rendered = thinking
169 }
170 rendered = strings.TrimSpace(rendered)
171
172 lines := strings.Split(rendered, "\n")
173 totalLines := len(lines)
174
175 isTruncated := totalLines > maxCollapsedThinkingHeight
176 if !a.thinkingExpanded && isTruncated {
177 lines = lines[totalLines-maxCollapsedThinkingHeight:]
178 hint := a.sty.Messages.ThinkingTruncationHint.Render(
179 fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
180 )
181 lines = append([]string{hint, ""}, lines...)
182 }
183
184 thinkingStyle := a.sty.Messages.ThinkingBox.Width(width)
185 result := thinkingStyle.Render(strings.Join(lines, "\n"))
186 a.thinkingBoxHeight = lipgloss.Height(result)
187
188 var footer string
189 // if thinking is done add the thought for footer
190 if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
191 duration := a.message.ThinkingDuration()
192 if duration.String() != "0s" {
193 footer = a.sty.Messages.ThinkingFooterTitle.Render("Thought for ") +
194 a.sty.Messages.ThinkingFooterDuration.Render(duration.String())
195 }
196 }
197
198 if footer != "" {
199 result += "\n\n" + footer
200 }
201
202 return result
203}
204
205// renderMarkdown renders content as markdown.
206func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
207 renderer := common.MarkdownRenderer(a.sty, width)
208 result, err := renderer.Render(content)
209 if err != nil {
210 return content
211 }
212 return strings.TrimSuffix(result, "\n")
213}
214
215func (a *AssistantMessageItem) renderSpinning() string {
216 if a.message.IsThinking() {
217 a.anim.SetLabel("Thinking")
218 } else if a.message.IsSummaryMessage {
219 a.anim.SetLabel("Summarizing")
220 }
221 return a.anim.Render()
222}
223
224// renderError renders an error message.
225func (a *AssistantMessageItem) renderError(width int) string {
226 finishPart := a.message.FinishPart()
227 errTag := a.sty.Messages.ErrorTag.Render("ERROR")
228 truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
229 title := fmt.Sprintf("%s %s", errTag, a.sty.Messages.ErrorTitle.Render(truncated))
230 details := a.sty.Messages.ErrorDetails.Width(width - 2).Render(finishPart.Details)
231 return fmt.Sprintf("%s\n\n%s", title, details)
232}
233
234// isSpinning returns true if the assistant message is still generating.
235func (a *AssistantMessageItem) isSpinning() bool {
236 isThinking := a.message.IsThinking()
237 isFinished := a.message.IsFinished()
238 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
239 hasToolCalls := len(a.message.ToolCalls()) > 0
240 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
241}
242
243// SetMessage is used to update the underlying message.
244func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
245 wasSpinning := a.isSpinning()
246 a.message = message
247 a.clearCache()
248 if !wasSpinning && a.isSpinning() {
249 return a.StartAnimation()
250 }
251 return nil
252}
253
254// ToggleExpanded toggles the expanded state of the thinking box and returns
255// whether the item is now expanded.
256func (a *AssistantMessageItem) ToggleExpanded() bool {
257 a.thinkingExpanded = !a.thinkingExpanded
258 a.clearCache()
259 return a.thinkingExpanded
260}
261
262// HandleMouseClick implements MouseClickable. It signals (via a true return)
263// that the click lies on the thinking box so the caller can invoke
264// [AssistantMessageItem.ToggleExpanded] through the generic [Expandable]
265// path. Toggling here directly would double-toggle because the caller always
266// runs the generic path after a handled click.
267func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
268 if btn != ansi.MouseLeft {
269 return false
270 }
271 // Only the thinking box is clickable; other regions of the assistant
272 // message should not trigger expansion.
273 return a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight
274}
275
276// HandleKeyEvent implements KeyEventHandler.
277func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
278 if k := key.String(); k == "c" || k == "y" {
279 text := a.message.Content().Text
280 return true, common.CopyToClipboard(text, "Message copied to clipboard")
281 }
282 return false, nil
283}