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