messages.go

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