messages.go

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