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