1package messages
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7 "time"
8
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/lipgloss/v2"
11
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/list"
18 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/util"
20)
21
22// MessageCmp defines the interface for message components in the chat interface.
23// It combines standard UI model interfaces with message-specific functionality.
24type MessageCmp interface {
25 util.Model // Basic Bubble Tea model interface
26 layout.Sizeable // Width/height management
27 layout.Focusable // Focus state management
28 GetMessage() message.Message // Access to underlying message data
29 Spinning() bool // Animation state for loading messages
30}
31
32// messageCmp implements the MessageCmp interface for displaying chat messages.
33// It handles rendering of user and assistant messages with proper styling,
34// animations, and state management.
35type messageCmp struct {
36 width int // Component width for text wrapping
37 focused bool // Focus state for border styling
38
39 // Core message data and state
40 message message.Message // The underlying message content
41 spinning bool // Whether to show loading animation
42 anim util.Model // Animation component for loading states
43}
44
45var focusedMessageBorder = lipgloss.Border{
46 Left: "▌",
47}
48
49// NewMessageCmp creates a new message component with the given message and options
50func NewMessageCmp(msg message.Message) MessageCmp {
51 t := styles.CurrentTheme()
52 m := &messageCmp{
53 message: msg,
54 anim: anim.New(anim.Settings{
55 Size: 15,
56 GradColorA: t.Primary,
57 GradColorB: t.Secondary,
58 CycleColors: true,
59 }),
60 }
61 return m
62}
63
64// Init initializes the message component and starts animations if needed.
65// Returns a command to start the animation for spinning messages.
66func (m *messageCmp) Init() tea.Cmd {
67 m.spinning = m.shouldSpin()
68 if m.spinning {
69 return m.anim.Init()
70 }
71 return nil
72}
73
74// Update handles incoming messages and updates the component state.
75// Manages animation updates for spinning messages and stops animation when appropriate.
76func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77 switch msg := msg.(type) {
78 case anim.StepMsg:
79 m.spinning = m.shouldSpin()
80 if m.spinning {
81 u, cmd := m.anim.Update(msg)
82 m.anim = u.(util.Model)
83 return m, cmd
84 }
85 }
86 return m, nil
87}
88
89// View renders the message component based on its current state.
90// Returns different views for spinning, user, and assistant messages.
91func (m *messageCmp) View() string {
92 if m.spinning {
93 return m.style().PaddingLeft(1).Render(m.anim.View())
94 }
95 if m.message.ID != "" {
96 // this is a user or assistant message
97 switch m.message.Role {
98 case message.User:
99 return m.renderUserMessage()
100 default:
101 return m.renderAssistantMessage()
102 }
103 }
104 return m.style().Render("No message content")
105}
106
107// GetMessage returns the underlying message data
108func (m *messageCmp) GetMessage() message.Message {
109 return m.message
110}
111
112// textWidth calculates the available width for text content,
113// accounting for borders and padding
114func (m *messageCmp) textWidth() int {
115 return m.width - 2 // take into account the border and/or padding
116}
117
118// style returns the lipgloss style for the message component.
119// Applies different border colors and styles based on message role and focus state.
120func (msg *messageCmp) style() lipgloss.Style {
121 t := styles.CurrentTheme()
122 borderStyle := lipgloss.NormalBorder()
123 if msg.focused {
124 borderStyle = focusedMessageBorder
125 }
126
127 style := t.S().Text
128 if msg.message.Role == message.User {
129 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
130 } else {
131 if msg.focused {
132 style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
133 } else {
134 style = style.PaddingLeft(2)
135 }
136 }
137 return style
138}
139
140// renderAssistantMessage renders assistant messages with optional footer information.
141// Shows model name, response time, and finish reason when the message is complete.
142func (m *messageCmp) renderAssistantMessage() string {
143 parts := []string{
144 m.markdownContent(),
145 }
146
147 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
148 return m.style().Render(joined)
149}
150
151// renderUserMessage renders user messages with file attachments.
152// Displays message content and any attached files with appropriate icons.
153func (m *messageCmp) renderUserMessage() string {
154 t := styles.CurrentTheme()
155 parts := []string{
156 m.markdownContent(),
157 }
158 attachmentStyles := t.S().Text.
159 MarginLeft(1).
160 Background(t.BgSubtle)
161 attachments := []string{}
162 for _, attachment := range m.message.BinaryContent() {
163 file := filepath.Base(attachment.Path)
164 var filename string
165 if len(file) > 10 {
166 filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
167 } else {
168 filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
169 }
170 attachments = append(attachments, attachmentStyles.Render(filename))
171 }
172 if len(attachments) > 0 {
173 parts = append(parts, "", strings.Join(attachments, ""))
174 }
175 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
176 return m.style().Render(joined)
177}
178
179// toMarkdown converts text content to rendered markdown using the configured renderer
180func (m *messageCmp) toMarkdown(content string) string {
181 r := styles.GetMarkdownRenderer(m.textWidth())
182 rendered, _ := r.Render(content)
183 return strings.TrimSuffix(rendered, "\n")
184}
185
186// markdownContent processes the message content and handles special states.
187// Returns appropriate content for thinking, finished, and error states.
188func (m *messageCmp) markdownContent() string {
189 content := m.message.Content().String()
190 if m.message.Role == message.Assistant {
191 thinking := m.message.IsThinking()
192 finished := m.message.IsFinished()
193 finishedData := m.message.FinishPart()
194 if thinking {
195 // Handle the thinking state
196 // TODO: maybe add the thinking content if available later.
197 content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
198 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
199 // Sometimes the LLMs respond with no content when they think the previous tool result
200 // provides the requested question
201 content = ""
202 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
203 content = "*Canceled*"
204 }
205 }
206 return m.toMarkdown(content)
207}
208
209// shouldSpin determines whether the message should show a loading animation.
210// Only assistant messages without content that aren't finished should spin.
211func (m *messageCmp) shouldSpin() bool {
212 if m.message.Role != message.Assistant {
213 return false
214 }
215
216 if m.message.IsFinished() {
217 return false
218 }
219
220 if m.message.Content().Text != "" {
221 return false
222 }
223 return true
224}
225
226// Focus management methods
227
228// Blur removes focus from the message component
229func (m *messageCmp) Blur() tea.Cmd {
230 m.focused = false
231 return nil
232}
233
234// Focus sets focus on the message component
235func (m *messageCmp) Focus() tea.Cmd {
236 m.focused = true
237 return nil
238}
239
240// IsFocused returns whether the message component is currently focused
241func (m *messageCmp) IsFocused() bool {
242 return m.focused
243}
244
245// Size management methods
246
247// GetSize returns the current dimensions of the message component
248func (m *messageCmp) GetSize() (int, int) {
249 return m.width, 0
250}
251
252// SetSize updates the width of the message component for text wrapping
253func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
254 // For better readability, we limit the width to a maximum of 120 characters
255 m.width = min(width, 120)
256 return nil
257}
258
259// Spinning returns whether the message is currently showing a loading animation
260func (m *messageCmp) Spinning() bool {
261 return m.spinning
262}
263
264type AssistantSection interface {
265 util.Model
266 layout.Sizeable
267 list.SectionHeader
268}
269type assistantSectionModel struct {
270 width int
271 message message.Message
272 lastUserMessageTime time.Time
273}
274
275func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
276 return &assistantSectionModel{
277 width: 0,
278 message: message,
279 lastUserMessageTime: lastUserMessageTime,
280 }
281}
282
283func (m *assistantSectionModel) Init() tea.Cmd {
284 return nil
285}
286
287func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
288 return m, nil
289}
290
291func (m *assistantSectionModel) View() string {
292 t := styles.CurrentTheme()
293 finishData := m.message.FinishPart()
294 finishTime := time.Unix(finishData.Time, 0)
295 duration := finishTime.Sub(m.lastUserMessageTime)
296 infoMsg := t.S().Subtle.Render(duration.String())
297 icon := t.S().Subtle.Render(styles.ModelIcon)
298 model := config.Get().GetModel(m.message.Provider, m.message.Model)
299 modelFormatted := t.S().Muted.Render(model.Name)
300 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
301 return t.S().Base.PaddingLeft(2).Render(
302 core.Section(assistant, m.width-2),
303 )
304}
305
306func (m *assistantSectionModel) GetSize() (int, int) {
307 return m.width, 1
308}
309
310func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
311 m.width = width
312 return nil
313}
314
315func (m *assistantSectionModel) IsSectionHeader() bool {
316 return true
317}