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