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/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.Spinner // 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 Static: isReduceAnimations(),
79 Size: 15,
80 GradColorA: t.Primary,
81 GradColorB: t.Secondary,
82 CycleColors: true,
83 }),
84 thinkingViewport: thinkingViewport,
85 }
86 return m
87}
88
89// Init initializes the message component and starts animations if needed.
90// Returns a command to start the animation for spinning messages.
91func (m *messageCmp) Init() tea.Cmd {
92 m.spinning = m.shouldSpin()
93 return m.anim.Init()
94}
95
96// Update handles incoming messages and updates the component state.
97// Manages animation updates for spinning messages and stops animation when appropriate.
98func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
99 switch msg := msg.(type) {
100 case anim.StepMsg:
101 m.spinning = m.shouldSpin()
102 if m.spinning {
103 var cmd tea.Cmd
104 m.anim, cmd = m.anim.Update(msg)
105 return m, cmd
106 }
107 case tea.KeyPressMsg:
108 if key.Matches(msg, CopyKey) {
109 return m, tea.Sequence(
110 tea.SetClipboard(m.message.Content().Text),
111 func() tea.Msg {
112 _ = clipboard.WriteAll(m.message.Content().Text)
113 return nil
114 },
115 util.ReportInfo("Message copied to clipboard"),
116 )
117 }
118 }
119 return m, nil
120}
121
122// View renders the message component based on its current state.
123// Returns different views for spinning, user, and assistant messages.
124func (m *messageCmp) View() string {
125 if m.spinning && m.message.ReasoningContent().Thinking == "" {
126 return m.style().PaddingLeft(1).Render(m.anim.View())
127 }
128 if m.message.ID != "" {
129 // this is a user or assistant message
130 switch m.message.Role {
131 case message.User:
132 return m.renderUserMessage()
133 default:
134 return m.renderAssistantMessage()
135 }
136 }
137 return m.style().Render("No message content")
138}
139
140// GetMessage returns the underlying message data
141func (m *messageCmp) GetMessage() message.Message {
142 return m.message
143}
144
145func (m *messageCmp) SetMessage(msg message.Message) {
146 m.message = msg
147}
148
149// textWidth calculates the available width for text content,
150// accounting for borders and padding
151func (m *messageCmp) textWidth() int {
152 return m.width - 2 // take into account the border and/or padding
153}
154
155// style returns the lipgloss style for the message component.
156// Applies different border colors and styles based on message role and focus state.
157func (msg *messageCmp) style() lipgloss.Style {
158 t := styles.CurrentTheme()
159 borderStyle := lipgloss.NormalBorder()
160 if msg.focused {
161 borderStyle = focusedMessageBorder
162 }
163
164 style := t.S().Text
165 if msg.message.Role == message.User {
166 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
167 } else {
168 if msg.focused {
169 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
170 } else {
171 style = style.PaddingLeft(2)
172 }
173 }
174 return style
175}
176
177// renderAssistantMessage renders assistant messages with optional footer information.
178// Shows model name, response time, and finish reason when the message is complete.
179func (m *messageCmp) renderAssistantMessage() string {
180 t := styles.CurrentTheme()
181 parts := []string{}
182 content := m.message.Content().String()
183 thinking := m.message.IsThinking()
184 finished := m.message.IsFinished()
185 finishedData := m.message.FinishPart()
186 thinkingContent := ""
187
188 if thinking || m.message.ReasoningContent().Thinking != "" {
189 m.anim.SetLabel("Thinking")
190 thinkingContent = m.renderThinkingContent()
191 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
192 content = ""
193 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
194 content = "*Canceled*"
195 } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
196 errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
197 truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
198 title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
199 details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
200 errorContent := fmt.Sprintf("%s\n\n%s", title, details)
201 return m.style().Render(errorContent)
202 }
203
204 if thinkingContent != "" {
205 parts = append(parts, thinkingContent)
206 }
207
208 if content != "" {
209 if thinkingContent != "" {
210 parts = append(parts, "")
211 }
212 parts = append(parts, m.toMarkdown(content))
213 }
214
215 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
216 return m.style().Render(joined)
217}
218
219// renderUserMessage renders user messages with file attachments. It displays
220// message content and any attached files with appropriate icons.
221func (m *messageCmp) renderUserMessage() string {
222 t := styles.CurrentTheme()
223 parts := []string{
224 m.toMarkdown(m.message.Content().String()),
225 }
226
227 attachmentStyles := t.S().Text.
228 MarginLeft(1).
229 Background(t.BgSubtle)
230
231 attachments := make([]string, len(m.message.BinaryContent()))
232 for i, attachment := range m.message.BinaryContent() {
233 const maxFilenameWidth = 10
234 filename := filepath.Base(attachment.Path)
235 attachments[i] = attachmentStyles.Render(fmt.Sprintf(
236 " %s %s ",
237 styles.DocumentIcon,
238 ansi.Truncate(filename, maxFilenameWidth, "..."),
239 ))
240 }
241
242 if len(attachments) > 0 {
243 parts = append(parts, "", strings.Join(attachments, ""))
244 }
245
246 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
247 return m.style().Render(joined)
248}
249
250// toMarkdown converts text content to rendered markdown using the configured renderer
251func (m *messageCmp) toMarkdown(content string) string {
252 r := styles.GetMarkdownRenderer(m.textWidth())
253 rendered, _ := r.Render(content)
254 return strings.TrimSuffix(rendered, "\n")
255}
256
257func (m *messageCmp) renderThinkingContent() string {
258 t := styles.CurrentTheme()
259 reasoningContent := m.message.ReasoningContent()
260 if reasoningContent.Thinking == "" {
261 return ""
262 }
263 lines := strings.Split(reasoningContent.Thinking, "\n")
264 var content strings.Builder
265 lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
266 for i, line := range lines {
267 if line == "" {
268 continue
269 }
270 content.WriteString(lineStyle.Width(m.textWidth() - 2).Render(line))
271 if i < len(lines)-1 {
272 content.WriteString("\n")
273 }
274 }
275 fullContent := content.String()
276 height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
277 m.thinkingViewport.SetHeight(height)
278 m.thinkingViewport.SetWidth(m.textWidth())
279 m.thinkingViewport.SetContent(fullContent)
280 m.thinkingViewport.GotoBottom()
281 finishReason := m.message.FinishPart()
282 var footer string
283 if reasoningContent.StartedAt > 0 {
284 duration := m.message.ThinkingDuration()
285 if reasoningContent.FinishedAt > 0 {
286 m.anim.SetLabel("")
287 opts := core.StatusOpts{
288 Title: "Thought for",
289 Description: duration.String(),
290 }
291 if duration.String() != "0s" {
292 footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
293 }
294 } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
295 footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*"))
296 } else {
297 footer = m.anim.View()
298 }
299 }
300 return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
301}
302
303// shouldSpin determines whether the message should show a loading animation.
304// Only assistant messages without content that aren't finished should spin.
305func (m *messageCmp) shouldSpin() bool {
306 if m.message.Role != message.Assistant {
307 return false
308 }
309
310 if m.message.IsFinished() {
311 return false
312 }
313
314 if m.message.Content().Text != "" {
315 return false
316 }
317 if len(m.message.ToolCalls()) > 0 {
318 return false
319 }
320 return true
321}
322
323// Blur removes focus from the message component
324func (m *messageCmp) Blur() tea.Cmd {
325 m.focused = false
326 return nil
327}
328
329// Focus sets focus on the message component
330func (m *messageCmp) Focus() tea.Cmd {
331 m.focused = true
332 return nil
333}
334
335// IsFocused returns whether the message component is currently focused
336func (m *messageCmp) IsFocused() bool {
337 return m.focused
338}
339
340// Size management methods
341
342// GetSize returns the current dimensions of the message component
343func (m *messageCmp) GetSize() (int, int) {
344 return m.width, 0
345}
346
347// SetSize updates the width of the message component for text wrapping
348func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
349 m.width = ordered.Clamp(width, 1, 120)
350 m.thinkingViewport.SetWidth(m.width - 4)
351 return nil
352}
353
354// Spinning returns whether the message is currently showing a loading animation
355func (m *messageCmp) Spinning() bool {
356 return m.spinning
357}
358
359type AssistantSection interface {
360 list.Item
361 layout.Sizeable
362}
363type assistantSectionModel struct {
364 width int
365 id string
366 message message.Message
367 lastUserMessageTime time.Time
368}
369
370// ID implements AssistantSection.
371func (m *assistantSectionModel) ID() string {
372 return m.id
373}
374
375func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
376 return &assistantSectionModel{
377 width: 0,
378 id: uuid.NewString(),
379 message: message,
380 lastUserMessageTime: lastUserMessageTime,
381 }
382}
383
384func (m *assistantSectionModel) Init() tea.Cmd {
385 return nil
386}
387
388func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
389 return m, nil
390}
391
392func (m *assistantSectionModel) View() string {
393 t := styles.CurrentTheme()
394 finishData := m.message.FinishPart()
395 finishTime := time.Unix(finishData.Time, 0)
396 duration := finishTime.Sub(m.lastUserMessageTime)
397 infoMsg := t.S().Subtle.Render(duration.String())
398 icon := t.S().Subtle.Render(styles.ModelIcon)
399 model := config.Get().GetModel(m.message.Provider, m.message.Model)
400 if model == nil {
401 // This means the model is not configured anymore
402 model = &catwalk.Model{
403 Name: "Unknown Model",
404 }
405 }
406 modelFormatted := t.S().Muted.Render(model.Name)
407 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
408 return t.S().Base.PaddingLeft(2).Render(
409 core.Section(assistant, m.width-2),
410 )
411}
412
413func (m *assistantSectionModel) GetSize() (int, int) {
414 return m.width, 1
415}
416
417func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
418 m.width = width
419 return nil
420}
421
422func (m *assistantSectionModel) IsSectionHeader() bool {
423 return true
424}
425
426func (m *messageCmp) ID() string {
427 return m.message.ID
428}
429
430func isReduceAnimations() bool {
431 cfg := config.Get()
432 return cfg.Options != nil &&
433 cfg.Options.TUI != nil &&
434 cfg.Options.TUI.ReduceAnimations != nil &&
435 *cfg.Options.TUI.ReduceAnimations
436}