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