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