1package messages
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "github.com/charmbracelet/bubbles/v2/key"
10 "github.com/charmbracelet/bubbles/v2/viewport"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/x/ansi"
15 "github.com/google/uuid"
16
17 "github.com/atotto/clipboard"
18 "github.com/charmbracelet/crush/internal/config"
19 "github.com/charmbracelet/crush/internal/message"
20 "github.com/charmbracelet/crush/internal/tui/components/anim"
21 "github.com/charmbracelet/crush/internal/tui/components/core"
22 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
23 "github.com/charmbracelet/crush/internal/tui/exp/list"
24 "github.com/charmbracelet/crush/internal/tui/styles"
25 "github.com/charmbracelet/crush/internal/tui/util"
26)
27
28var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
29
30// MessageCmp defines the interface for message components in the chat interface.
31// It combines standard UI model interfaces with message-specific functionality.
32type MessageCmp interface {
33 util.Model // Basic Bubble Tea model interface
34 layout.Sizeable // Width/height management
35 layout.Focusable // Focus state management
36 GetMessage() message.Message // Access to underlying message data
37 SetMessage(msg message.Message) // Update the message content
38 Spinning() bool // Animation state for loading messages
39 ID() string
40}
41
42// messageCmp implements the MessageCmp interface for displaying chat messages.
43// It handles rendering of user and assistant messages with proper styling,
44// animations, and state management.
45type messageCmp struct {
46 width int // Component width for text wrapping
47 focused bool // Focus state for border styling
48
49 // Core message data and state
50 message message.Message // The underlying message content
51 spinning bool // Whether to show loading animation
52 anim *anim.Anim // Animation component for loading states
53
54 // Thinking viewport for displaying reasoning content
55 thinkingViewport viewport.Model
56}
57
58var focusedMessageBorder = lipgloss.Border{
59 Left: "▌",
60}
61
62// NewMessageCmp creates a new message component with the given message and options
63func NewMessageCmp(msg message.Message) MessageCmp {
64 t := styles.CurrentTheme()
65
66 thinkingViewport := viewport.New()
67 thinkingViewport.SetHeight(1)
68 thinkingViewport.KeyMap = viewport.KeyMap{}
69
70 m := &messageCmp{
71 message: msg,
72 anim: anim.New(anim.Settings{
73 Size: 15,
74 GradColorA: t.Primary,
75 GradColorB: t.Secondary,
76 CycleColors: true,
77 }),
78 thinkingViewport: thinkingViewport,
79 }
80 return m
81}
82
83// Init initializes the message component and starts animations if needed.
84// Returns a command to start the animation for spinning messages.
85func (m *messageCmp) Init() tea.Cmd {
86 m.spinning = m.shouldSpin()
87 return m.anim.Init()
88}
89
90// Update handles incoming messages and updates the component state.
91// Manages animation updates for spinning messages and stops animation when appropriate.
92func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
93 switch msg := msg.(type) {
94 case anim.StepMsg:
95 m.spinning = m.shouldSpin()
96 if m.spinning {
97 u, cmd := m.anim.Update(msg)
98 m.anim = u.(*anim.Anim)
99 return m, cmd
100 }
101 case tea.KeyPressMsg:
102 if key.Matches(msg, copyKey) {
103 err := clipboard.WriteAll(m.message.Content().Text)
104 if err != nil {
105 return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
106 }
107 return m, util.ReportInfo("Message copied to clipboard")
108 }
109 }
110 return m, nil
111}
112
113// View renders the message component based on its current state.
114// Returns different views for spinning, user, and assistant messages.
115func (m *messageCmp) View() string {
116 if m.spinning && m.message.ReasoningContent().Thinking == "" {
117 return m.style().PaddingLeft(1).Render(m.anim.View())
118 }
119 if m.message.ID != "" {
120 // this is a user or assistant message
121 switch m.message.Role {
122 case message.User:
123 return m.renderUserMessage()
124 default:
125 return m.renderAssistantMessage()
126 }
127 }
128 return m.style().Render("No message content")
129}
130
131// GetMessage returns the underlying message data
132func (m *messageCmp) GetMessage() message.Message {
133 return m.message
134}
135
136func (m *messageCmp) SetMessage(msg message.Message) {
137 m.message = msg
138}
139
140// textWidth calculates the available width for text content,
141// accounting for borders and padding
142func (m *messageCmp) textWidth() int {
143 return m.width - 2 // take into account the border and/or padding
144}
145
146// style returns the lipgloss style for the message component.
147// Applies different border colors and styles based on message role and focus state.
148func (msg *messageCmp) style() lipgloss.Style {
149 t := styles.CurrentTheme()
150 borderStyle := lipgloss.NormalBorder()
151 if msg.focused {
152 borderStyle = focusedMessageBorder
153 }
154
155 style := t.S().Text
156 if msg.message.Role == message.User {
157 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
158 } else {
159 if msg.focused {
160 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
161 } else {
162 style = style.PaddingLeft(2)
163 }
164 }
165 return style
166}
167
168// renderAssistantMessage renders assistant messages with optional footer information.
169// Shows model name, response time, and finish reason when the message is complete.
170func (m *messageCmp) renderAssistantMessage() string {
171 t := styles.CurrentTheme()
172 parts := []string{}
173 content := m.message.Content().String()
174 thinking := m.message.IsThinking()
175 finished := m.message.IsFinished()
176 finishedData := m.message.FinishPart()
177 thinkingContent := ""
178 retryContent := m.renderRetryContent()
179
180 if thinking || m.message.ReasoningContent().Thinking != "" {
181 m.anim.SetLabel("Thinking")
182 thinkingContent = m.renderThinkingContent()
183 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
184 content = ""
185 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
186 content = "*Canceled*"
187 } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
188 errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
189 truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
190 title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
191 details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
192 errorContent := fmt.Sprintf("%s\n\n%s", title, details)
193 return m.style().Render(errorContent)
194 }
195
196 if retryContent != "" {
197 parts = append(parts, retryContent)
198 }
199
200 if thinkingContent != "" {
201 if retryContent != "" {
202 parts = append(parts, "")
203 }
204 parts = append(parts, thinkingContent)
205 }
206
207 if content != "" {
208 if thinkingContent != "" || retryContent != "" {
209 parts = append(parts, "")
210 }
211 parts = append(parts, m.toMarkdown(content))
212 }
213
214 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
215 return m.style().Render(joined)
216}
217
218// renderUserMessage renders user messages with file attachments. It displays
219// message content and any attached files with appropriate icons.
220func (m *messageCmp) renderUserMessage() string {
221 t := styles.CurrentTheme()
222 parts := []string{
223 m.toMarkdown(m.message.Content().String()),
224 }
225
226 attachmentStyles := t.S().Text.
227 MarginLeft(1).
228 Background(t.BgSubtle)
229
230 attachments := make([]string, len(m.message.BinaryContent()))
231 for i, attachment := range m.message.BinaryContent() {
232 const maxFilenameWidth = 10
233 filename := filepath.Base(attachment.Path)
234 attachments[i] = attachmentStyles.Render(fmt.Sprintf(
235 " %s %s ",
236 styles.DocumentIcon,
237 ansi.Truncate(filename, maxFilenameWidth, "..."),
238 ))
239 }
240
241 if len(attachments) > 0 {
242 parts = append(parts, "", strings.Join(attachments, ""))
243 }
244
245 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
246 return m.style().Render(joined)
247}
248
249// toMarkdown converts text content to rendered markdown using the configured renderer
250func (m *messageCmp) toMarkdown(content string) string {
251 r := styles.GetMarkdownRenderer(m.textWidth())
252 rendered, _ := r.Render(content)
253 return strings.TrimSuffix(rendered, "\n")
254}
255
256func (m *messageCmp) renderThinkingContent() string {
257 t := styles.CurrentTheme()
258 reasoningContent := m.message.ReasoningContent()
259 if reasoningContent.Thinking == "" {
260 return ""
261 }
262 lines := strings.Split(reasoningContent.Thinking, "\n")
263 var content strings.Builder
264 lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
265 for i, line := range lines {
266 if line == "" {
267 continue
268 }
269 content.WriteString(lineStyle.Width(m.textWidth() - 2).Render(line))
270 if i < len(lines)-1 {
271 content.WriteString("\n")
272 }
273 }
274 fullContent := content.String()
275 height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
276 m.thinkingViewport.SetHeight(height)
277 m.thinkingViewport.SetWidth(m.textWidth())
278 m.thinkingViewport.SetContent(fullContent)
279 m.thinkingViewport.GotoBottom()
280 finishReason := m.message.FinishPart()
281 var footer string
282 if reasoningContent.StartedAt > 0 {
283 duration := m.message.ThinkingDuration()
284 if reasoningContent.FinishedAt > 0 {
285 m.anim.SetLabel("")
286 opts := core.StatusOpts{
287 Title: "Thought for",
288 Description: duration.String(),
289 NoIcon: true,
290 }
291 return t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
292 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
293 footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*"))
294 } else {
295 footer = m.anim.View()
296 }
297 }
298 return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
299}
300
301func (m *messageCmp) renderRetryContent() string {
302 t := styles.CurrentTheme()
303 retryContent := m.message.RetryContent()
304 if retryContent == nil || len(retryContent.Retries) == 0 {
305 return ""
306 }
307
308 // Get the latest retry for the main display
309 latestRetry := retryContent.Retries[len(retryContent.Retries)-1]
310
311 var title string
312 var details string
313 retryDuration := time.Duration(latestRetry.RetryAfter) * time.Millisecond
314
315 if strings.Contains(latestRetry.Error, "426") || strings.Contains(strings.ToLower(latestRetry.Error), "rate limited") {
316 // Rate limit retry
317 warningTag := t.S().Base.Padding(0, 1).Background(t.Warning).Foreground(t.BgBase).Render("RATE LIMITED")
318 retryMsg := fmt.Sprintf("Retrying after %s", retryDuration.String())
319 title = fmt.Sprintf("%s %s", warningTag, t.S().Base.Foreground(t.FgHalfMuted).Render(retryMsg))
320 } else {
321 // Error retry
322 warningTag := t.S().Base.Padding(0, 1).Background(t.Warning).Foreground(t.BgBase).Render("RETRYING")
323 truncated := ansi.Truncate(latestRetry.Error, m.textWidth()-2-lipgloss.Width(warningTag), "...")
324 title = fmt.Sprintf("%s %s", warningTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
325 }
326
327 // Show retry history as details
328 if len(retryContent.Retries) > 1 {
329 var retryHistory []string
330
331 for i, retry := range retryContent.Retries {
332 timestamp := time.Unix(retry.Timestamp, 0).Format("15:04:05")
333 retryDuration := time.Duration(retry.RetryAfter) * time.Millisecond
334 var retryMsg string
335 if retry.Error == "" {
336 retryMsg = fmt.Sprintf("Rate limited, retrying after %s", retryDuration.String())
337 } else {
338 retryMsg = fmt.Sprintf("Error: %s (retry after %s)", retry.Error, retryDuration.String())
339 }
340 retryHistory = append(retryHistory, fmt.Sprintf("Attempt %d (%s): %s", i+1, timestamp, retryMsg))
341 }
342 details = strings.Join(retryHistory, "\n")
343 } else {
344 // Single retry, show timestamp
345 timestamp := time.Unix(latestRetry.Timestamp, 0).Format("15:04:05")
346 details = fmt.Sprintf("First attempt at %s", timestamp)
347 }
348
349 // Add current status if actively retrying
350 if retryContent.Retrying {
351 details += "\n\nCurrently retrying..."
352 }
353
354 detailsFormatted := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(details)
355 retryDisplay := fmt.Sprintf("%s\n\n%s", title, detailsFormatted)
356
357 return retryDisplay
358}
359
360// shouldSpin determines whether the message should show a loading animation.
361// Only assistant messages without content that aren't finished should spin.
362// Also considers retry state - only spins when actively retrying.
363func (m *messageCmp) shouldSpin() bool {
364 if m.message.Role != message.Assistant {
365 return false
366 }
367
368 if m.message.IsFinished() {
369 return false
370 }
371
372 // Check retry state - only spin if actively retrying
373 if retryContent := m.message.RetryContent(); retryContent != nil {
374 return retryContent.Retrying
375 }
376
377 if m.message.Content().Text != "" {
378 return false
379 }
380 if len(m.message.ToolCalls()) > 0 {
381 return false
382 }
383 return true
384}
385
386// Blur removes focus from the message component
387func (m *messageCmp) Blur() tea.Cmd {
388 m.focused = false
389 return nil
390}
391
392// Focus sets focus on the message component
393func (m *messageCmp) Focus() tea.Cmd {
394 m.focused = true
395 return nil
396}
397
398// IsFocused returns whether the message component is currently focused
399func (m *messageCmp) IsFocused() bool {
400 return m.focused
401}
402
403// Size management methods
404
405// GetSize returns the current dimensions of the message component
406func (m *messageCmp) GetSize() (int, int) {
407 return m.width, 0
408}
409
410// SetSize updates the width of the message component for text wrapping
411func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
412 m.width = util.Clamp(width, 1, 120)
413 m.thinkingViewport.SetWidth(m.width - 4)
414 return nil
415}
416
417// Spinning returns whether the message is currently showing a loading animation
418func (m *messageCmp) Spinning() bool {
419 return m.spinning
420}
421
422type AssistantSection interface {
423 list.Item
424 layout.Sizeable
425}
426type assistantSectionModel struct {
427 width int
428 id string
429 message message.Message
430 lastUserMessageTime time.Time
431}
432
433// ID implements AssistantSection.
434func (m *assistantSectionModel) ID() string {
435 return m.id
436}
437
438func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
439 return &assistantSectionModel{
440 width: 0,
441 id: uuid.NewString(),
442 message: message,
443 lastUserMessageTime: lastUserMessageTime,
444 }
445}
446
447func (m *assistantSectionModel) Init() tea.Cmd {
448 return nil
449}
450
451func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
452 return m, nil
453}
454
455func (m *assistantSectionModel) View() string {
456 t := styles.CurrentTheme()
457 finishData := m.message.FinishPart()
458 finishTime := time.Unix(finishData.Time, 0)
459 duration := finishTime.Sub(m.lastUserMessageTime)
460 infoMsg := t.S().Subtle.Render(duration.String())
461 icon := t.S().Subtle.Render(styles.ModelIcon)
462 model := config.Get().GetModel(m.message.Provider, m.message.Model)
463 if model == nil {
464 // This means the model is not configured anymore
465 model = &catwalk.Model{
466 Name: "Unknown Model",
467 }
468 }
469 modelFormatted := t.S().Muted.Render(model.Name)
470 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
471 return t.S().Base.PaddingLeft(2).Render(
472 core.Section(assistant, m.width-2),
473 )
474}
475
476func (m *assistantSectionModel) GetSize() (int, int) {
477 return m.width, 1
478}
479
480func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
481 m.width = width
482 return nil
483}
484
485func (m *assistantSectionModel) IsSectionHeader() bool {
486 return true
487}
488
489func (m *messageCmp) ID() string {
490 return m.message.ID
491}