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