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