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