1package chat
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "charm.land/bubbles/v2/key"
9 tea "charm.land/bubbletea/v2"
10 "charm.land/lipgloss/v2"
11 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common/anim"
14 "github.com/charmbracelet/crush/internal/ui/list"
15 "github.com/charmbracelet/crush/internal/ui/styles"
16 "github.com/charmbracelet/x/ansi"
17)
18
19const maxCollapsedThinkingHeight = 10
20
21// AssistantMessageItem represents an assistant message that can be displayed
22// in the chat UI.
23type AssistantMessageItem struct {
24 id string
25 content string
26 thinking string
27 finished bool
28 finish message.Finish
29 sty *styles.Styles
30 thinkingExpanded bool
31 thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
32
33 spinning bool
34 anim *anim.Anim
35 hasToolCalls bool
36 isSummaryMessage bool
37
38 thinkingStartedAt int64
39 thinkingFinishedAt int64
40}
41
42// NewAssistantMessage creates a new assistant message item.
43func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, hasToolCalls, isSummaryMessage bool, thinkingStartedAt, thinkingFinishedAt int64, sty *styles.Styles) *AssistantMessageItem {
44 m := &AssistantMessageItem{
45 id: id,
46 content: content,
47 thinking: thinking,
48 finished: finished,
49 finish: finish,
50 hasToolCalls: hasToolCalls,
51 isSummaryMessage: isSummaryMessage,
52 thinkingStartedAt: thinkingStartedAt,
53 thinkingFinishedAt: thinkingFinishedAt,
54 sty: sty,
55 }
56
57 m.anim = anim.New(anim.Settings{
58 Size: 15,
59 GradColorA: sty.Primary,
60 GradColorB: sty.Secondary,
61 LabelColor: sty.FgBase,
62 CycleColors: true,
63 })
64 m.spinning = m.shouldSpin()
65
66 return m
67}
68
69// shouldSpin returns true if the message should show loading animation.
70func (m *AssistantMessageItem) shouldSpin() bool {
71 if m.finished {
72 return false
73 }
74 if strings.TrimSpace(m.content) != "" {
75 return false
76 }
77 if m.hasToolCalls {
78 return false
79 }
80 return true
81}
82
83// ID implements Identifiable.
84func (m *AssistantMessageItem) ID() string {
85 return m.id
86}
87
88// FocusStyle returns the focus style.
89func (m *AssistantMessageItem) FocusStyle() lipgloss.Style {
90 return m.sty.Chat.Message.AssistantFocused
91}
92
93// BlurStyle returns the blur style.
94func (m *AssistantMessageItem) BlurStyle() lipgloss.Style {
95 return m.sty.Chat.Message.AssistantBlurred
96}
97
98// HighlightStyle returns the highlight style.
99func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style {
100 return m.sty.TextSelection
101}
102
103// Render implements list.Item.
104func (m *AssistantMessageItem) Render(width int) string {
105 if m.spinning && m.thinking == "" {
106 if m.isSummaryMessage {
107 m.anim.SetLabel("Summarizing")
108 }
109 return m.anim.View()
110 }
111
112 cappedWidth := min(width, maxTextWidth)
113 content := strings.TrimSpace(m.content)
114 thinking := strings.TrimSpace(m.thinking)
115
116 if m.finished && content == "" {
117 switch m.finish.Reason {
118 case message.FinishReasonEndTurn:
119 return ""
120 case message.FinishReasonCanceled:
121 return m.renderMarkdown("*Canceled*", cappedWidth)
122 case message.FinishReasonError:
123 return m.renderError(cappedWidth)
124 }
125 }
126
127 var parts []string
128 if thinking != "" {
129 parts = append(parts, m.renderThinking(thinking, cappedWidth))
130 }
131
132 if content != "" {
133 if len(parts) > 0 {
134 parts = append(parts, "")
135 }
136 parts = append(parts, m.renderMarkdown(content, cappedWidth))
137 }
138
139 return lipgloss.JoinVertical(lipgloss.Left, parts...)
140}
141
142// Update implements list.Updatable for handling animation updates.
143func (m *AssistantMessageItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
144 switch msg.(type) {
145 case anim.StepMsg:
146 m.spinning = m.shouldSpin()
147 if !m.spinning {
148 return m, nil
149 }
150 updatedAnim, cmd := m.anim.Update(msg)
151 m.anim = updatedAnim
152 if cmd != nil {
153 return m, cmd
154 }
155 }
156
157 return m, nil
158}
159
160// InitAnimation initializes and starts the animation.
161func (m *AssistantMessageItem) InitAnimation() tea.Cmd {
162 m.spinning = m.shouldSpin()
163 return m.anim.Init()
164}
165
166// SetContent updates the assistant message with new content.
167func (m *AssistantMessageItem) SetContent(content, thinking string, finished bool, finish *message.Finish, hasToolCalls, isSummaryMessage bool, reasoning message.ReasoningContent) {
168 m.content = content
169 m.thinking = thinking
170 m.finished = finished
171 if finish != nil {
172 m.finish = *finish
173 }
174 m.hasToolCalls = hasToolCalls
175 m.isSummaryMessage = isSummaryMessage
176 m.thinkingStartedAt = reasoning.StartedAt
177 m.thinkingFinishedAt = reasoning.FinishedAt
178 m.spinning = m.shouldSpin()
179}
180
181// renderMarkdown renders content as markdown.
182func (m *AssistantMessageItem) renderMarkdown(content string, width int) string {
183 renderer := common.MarkdownRenderer(m.sty, width)
184 result, err := renderer.Render(content)
185 if err != nil {
186 return content
187 }
188 return strings.TrimSuffix(result, "\n")
189}
190
191// renderThinking renders the thinking/reasoning content with footer.
192func (m *AssistantMessageItem) renderThinking(thinking string, width int) string {
193 renderer := common.PlainMarkdownRenderer(m.sty, width-2)
194 rendered, err := renderer.Render(thinking)
195 if err != nil {
196 rendered = thinking
197 }
198 rendered = strings.TrimSpace(rendered)
199
200 lines := strings.Split(rendered, "\n")
201 totalLines := len(lines)
202
203 isTruncated := totalLines > maxCollapsedThinkingHeight
204 if !m.thinkingExpanded && isTruncated {
205 lines = lines[totalLines-maxCollapsedThinkingHeight:]
206 }
207
208 if !m.thinkingExpanded && isTruncated {
209 hint := m.sty.Chat.Message.ThinkingTruncationHint.Render(
210 fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
211 )
212 lines = append([]string{hint}, lines...)
213 }
214
215 thinkingStyle := m.sty.Chat.Message.ThinkingBox.Width(width)
216 result := thinkingStyle.Render(strings.Join(lines, "\n"))
217 m.thinkingBoxHeight = lipgloss.Height(result)
218
219 var footer string
220 if m.thinkingStartedAt > 0 {
221 if m.thinkingFinishedAt > 0 {
222 duration := time.Duration(m.thinkingFinishedAt-m.thinkingStartedAt) * time.Second
223 if duration.String() != "0s" {
224 footer = m.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
225 m.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
226 }
227 } else if m.finish.Reason == message.FinishReasonCanceled {
228 footer = m.sty.Chat.Message.ThinkingFooterCancelled.Render("Canceled")
229 } else {
230 m.anim.SetLabel("Thinking")
231 footer = m.anim.View()
232 }
233 }
234
235 if footer != "" {
236 result += "\n\n" + footer
237 }
238
239 return result
240}
241
242// HandleMouseClick implements list.MouseClickable.
243func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
244 if btn != ansi.MouseLeft {
245 return false
246 }
247
248 if m.thinking != "" && y < m.thinkingBoxHeight {
249 m.thinkingExpanded = !m.thinkingExpanded
250 return true
251 }
252
253 return false
254}
255
256// HandleKeyPress implements list.KeyPressable.
257func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
258 if m.thinking == "" {
259 return false
260 }
261
262 if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
263 m.thinkingExpanded = !m.thinkingExpanded
264 return true
265 }
266
267 return false
268}
269
270// renderError renders an error message.
271func (m *AssistantMessageItem) renderError(width int) string {
272 errTag := m.sty.Chat.Message.ErrorTag.Render("ERROR")
273 truncated := ansi.Truncate(m.finish.Message, width-2-lipgloss.Width(errTag), "...")
274 title := fmt.Sprintf("%s %s", errTag, m.sty.Chat.Message.ErrorTitle.Render(truncated))
275 details := m.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(m.finish.Details)
276 return fmt.Sprintf("%s\n\n%s", title, details)
277}