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