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