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