messages.go

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