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