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 blurredCache *cachedMessageItem
31 focusedCache *cachedMessageItem
32
33 message *message.Message
34 sty *styles.Styles
35 anim *anim.Anim
36 thinkingExpanded bool
37 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
38}
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 blurredCache: &cachedMessageItem{},
46 focusedCache: &cachedMessageItem{},
47 focusableMessageItem: &focusableMessageItem{},
48 message: message,
49 sty: sty,
50 }
51
52 a.anim = anim.New(anim.Settings{
53 ID: a.ID(),
54 Size: 15,
55 GradColorA: sty.Primary,
56 GradColorB: sty.Secondary,
57 LabelColor: sty.FgBase,
58 CycleColors: true,
59 })
60 return a
61}
62
63// StartAnimation starts the assistant message animation if it should be spinning.
64func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
65 if !a.isSpinning() {
66 return nil
67 }
68 return a.anim.Start()
69}
70
71// Animate progresses the assistant message animation if it should be spinning.
72func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
73 if !a.isSpinning() {
74 return nil
75 }
76 return a.anim.Animate(msg)
77}
78
79// ID implements MessageItem.
80func (a *AssistantMessageItem) ID() string {
81 return a.message.ID
82}
83
84// RawRender implements [MessageItem].
85func (a *AssistantMessageItem) RawRender(width int) string {
86 cappedWidth := cappedMessageWidth(width)
87
88 var spinner string
89 if a.isSpinning() {
90 spinner = a.renderSpinning()
91 }
92
93 content, height, ok := a.getCachedRender(cappedWidth)
94 if !ok {
95 content = a.renderMessageContent(cappedWidth)
96 height = lipgloss.Height(content)
97 // cache the rendered content
98 a.setCachedRender(content, cappedWidth, height)
99 }
100
101 highlightedContent := a.renderHighlighted(content, cappedWidth, height)
102 if spinner != "" {
103 if highlightedContent != "" {
104 highlightedContent += "\n\n"
105 }
106 return highlightedContent + spinner
107 }
108
109 return highlightedContent
110}
111
112// Render implements MessageItem.
113func (a *AssistantMessageItem) Render(width int) string {
114 cache := a.blurredCache
115 if a.focused {
116 cache = a.focusedCache
117 }
118
119 content, _, ok := cache.getCachedRender(width)
120 if !ok {
121 style := a.sty.Chat.Message.AssistantBlurred
122 if a.focused {
123 style = a.sty.Chat.Message.AssistantFocused
124 }
125 content = style.Render(a.RawRender(width))
126 cache.setCachedRender(content, width, lipgloss.Height(content))
127 }
128
129 return content
130}
131
132// renderMessageContent renders the message content including thinking, main content, and finish reason.
133func (a *AssistantMessageItem) renderMessageContent(width int) string {
134 var messageParts []string
135 thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
136 content := strings.TrimSpace(a.message.Content().Text)
137 // if the massage has reasoning content add that first
138 if thinking != "" {
139 messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
140 }
141
142 // then add the main content
143 if content != "" {
144 // add a spacer between thinking and content
145 if thinking != "" {
146 messageParts = append(messageParts, "")
147 }
148 messageParts = append(messageParts, a.renderMarkdown(content, width))
149 }
150
151 // finally add any finish reason info
152 if a.message.IsFinished() {
153 switch a.message.FinishReason() {
154 case message.FinishReasonCanceled:
155 messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
156 case message.FinishReasonError:
157 messageParts = append(messageParts, a.renderError(width))
158 }
159 }
160
161 return strings.Join(messageParts, "\n")
162}
163
164// renderThinking renders the thinking/reasoning content with footer.
165func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
166 renderer := common.PlainMarkdownRenderer(a.sty, width)
167 rendered, err := renderer.Render(thinking)
168 if err != nil {
169 rendered = thinking
170 }
171 rendered = strings.TrimSpace(rendered)
172
173 lines := strings.Split(rendered, "\n")
174 totalLines := len(lines)
175
176 isTruncated := totalLines > maxCollapsedThinkingHeight
177 if !a.thinkingExpanded && isTruncated {
178 lines = lines[totalLines-maxCollapsedThinkingHeight:]
179 hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
180 fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
181 )
182 lines = append([]string{hint, ""}, lines...)
183 }
184
185 thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
186 result := thinkingStyle.Render(strings.Join(lines, "\n"))
187 a.thinkingBoxHeight = lipgloss.Height(result)
188
189 var footer string
190 // if thinking is done add the thought for footer
191 if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
192 duration := a.message.ThinkingDuration()
193 if duration.String() != "0s" {
194 footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
195 a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
196 }
197 }
198
199 if footer != "" {
200 result += "\n\n" + footer
201 }
202
203 return result
204}
205
206// renderMarkdown renders content as markdown.
207func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
208 renderer := common.MarkdownRenderer(a.sty, width)
209 result, err := renderer.Render(content)
210 if err != nil {
211 return content
212 }
213 return strings.TrimSuffix(result, "\n")
214}
215
216func (a *AssistantMessageItem) renderSpinning() string {
217 if a.message.IsThinking() {
218 a.anim.SetLabel("Thinking")
219 } else if a.message.IsSummaryMessage {
220 a.anim.SetLabel("Summarizing")
221 }
222 return a.anim.Render()
223}
224
225// renderError renders an error message.
226func (a *AssistantMessageItem) renderError(width int) string {
227 finishPart := a.message.FinishPart()
228 errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
229 truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
230 title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
231 details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
232 return fmt.Sprintf("%s\n\n%s", title, details)
233}
234
235// isSpinning returns true if the assistant message is still generating.
236func (a *AssistantMessageItem) isSpinning() bool {
237 isThinking := a.message.IsThinking()
238 isFinished := a.message.IsFinished()
239 hasContent := strings.TrimSpace(a.message.Content().Text) != ""
240 hasToolCalls := len(a.message.ToolCalls()) > 0
241 return (isThinking || !isFinished) && !hasContent && !hasToolCalls
242}
243
244// SetMessage is used to update the underlying message.
245func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
246 wasSpinning := a.isSpinning()
247 a.message = message
248 a.clearCache()
249 if !wasSpinning && a.isSpinning() {
250 return a.StartAnimation()
251 }
252 return nil
253}
254
255// ToggleExpanded toggles the expanded state of the thinking box.
256func (a *AssistantMessageItem) ToggleExpanded() {
257 a.thinkingExpanded = !a.thinkingExpanded
258 a.clearCache()
259}
260
261// HandleMouseClick implements MouseClickable.
262func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
263 if btn != ansi.MouseLeft {
264 return false
265 }
266 // check if the click is within the thinking box
267 if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
268 a.ToggleExpanded()
269 return true
270 }
271 return false
272}
273
274// HandleKeyEvent implements KeyEventHandler.
275func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
276 if k := key.String(); k == "c" || k == "y" {
277 text := a.message.Content().Text
278 return true, common.CopyToClipboard(text, "Message copied to clipboard")
279 }
280 return false, nil
281}