messages.go

  1package messages
  2
  3import (
  4	"fmt"
  5	"path/filepath"
  6	"strings"
  7	"time"
  8
  9	"github.com/charmbracelet/bubbles/v2/spinner"
 10	tea "github.com/charmbracelet/bubbletea/v2"
 11	"github.com/charmbracelet/crush/internal/llm/models"
 12	"github.com/charmbracelet/lipgloss/v2"
 13
 14	"github.com/charmbracelet/crush/internal/message"
 15	"github.com/charmbracelet/crush/internal/tui/components/anim"
 16	"github.com/charmbracelet/crush/internal/tui/components/core"
 17	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 18	"github.com/charmbracelet/crush/internal/tui/styles"
 19	"github.com/charmbracelet/crush/internal/tui/util"
 20)
 21
 22// MessageCmp defines the interface for message components in the chat interface.
 23// It combines standard UI model interfaces with message-specific functionality.
 24type MessageCmp interface {
 25	util.Model                   // Basic Bubble Tea model interface
 26	layout.Sizeable              // Width/height management
 27	layout.Focusable             // Focus state management
 28	GetMessage() message.Message // Access to underlying message data
 29	Spinning() bool              // Animation state for loading messages
 30}
 31
 32// messageCmp implements the MessageCmp interface for displaying chat messages.
 33// It handles rendering of user and assistant messages with proper styling,
 34// animations, and state management.
 35type messageCmp struct {
 36	width   int  // Component width for text wrapping
 37	focused bool // Focus state for border styling
 38
 39	// Core message data and state
 40	message             message.Message // The underlying message content
 41	spinning            bool            // Whether to show loading animation
 42	anim                util.Model      // Animation component for loading states
 43	lastUserMessageTime time.Time       // Used for calculating response duration
 44}
 45
 46// MessageOption provides functional options for configuring message components
 47type MessageOption func(*messageCmp)
 48
 49// WithLastUserMessageTime sets the timestamp of the last user message
 50// for calculating assistant response duration
 51func WithLastUserMessageTime(t time.Time) MessageOption {
 52	return func(m *messageCmp) {
 53		m.lastUserMessageTime = t
 54	}
 55}
 56
 57// NewMessageCmp creates a new message component with the given message and options
 58func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
 59	m := &messageCmp{
 60		message: msg,
 61		anim:    anim.New(15, ""),
 62	}
 63	for _, opt := range opts {
 64		opt(m)
 65	}
 66	return m
 67}
 68
 69// Init initializes the message component and starts animations if needed.
 70// Returns a command to start the animation for spinning messages.
 71func (m *messageCmp) Init() tea.Cmd {
 72	m.spinning = m.shouldSpin()
 73	if m.spinning {
 74		return m.anim.Init()
 75	}
 76	return nil
 77}
 78
 79// Update handles incoming messages and updates the component state.
 80// Manages animation updates for spinning messages and stops animation when appropriate.
 81func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 82	switch msg := msg.(type) {
 83	case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
 84		m.spinning = m.shouldSpin()
 85		if m.spinning {
 86			u, cmd := m.anim.Update(msg)
 87			m.anim = u.(util.Model)
 88			return m, cmd
 89		}
 90	}
 91	return m, nil
 92}
 93
 94// View renders the message component based on its current state.
 95// Returns different views for spinning, user, and assistant messages.
 96func (m *messageCmp) View() tea.View {
 97	if m.spinning {
 98		return tea.NewView(m.style().PaddingLeft(1).Render(m.anim.View().String()))
 99	}
100	if m.message.ID != "" {
101		// this is a user or assistant message
102		switch m.message.Role {
103		case message.User:
104			return tea.NewView(m.renderUserMessage())
105		default:
106			return tea.NewView(m.renderAssistantMessage())
107		}
108	}
109	return tea.NewView(m.style().Render("No message content"))
110}
111
112// GetMessage returns the underlying message data
113func (m *messageCmp) GetMessage() message.Message {
114	return m.message
115}
116
117// textWidth calculates the available width for text content,
118// accounting for borders and padding
119func (m *messageCmp) textWidth() int {
120	return m.width - 2 // take into account the border and/or padding
121}
122
123// style returns the lipgloss style for the message component.
124// Applies different border colors and styles based on message role and focus state.
125func (msg *messageCmp) style() lipgloss.Style {
126	t := styles.CurrentTheme()
127	borderStyle := lipgloss.NormalBorder()
128	if msg.focused {
129		borderStyle = lipgloss.ThickBorder()
130	}
131
132	style := t.S().Text
133	if msg.message.Role == message.User {
134		style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
135	} else {
136		if msg.focused {
137			style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
138		} else {
139			style = style.PaddingLeft(2)
140		}
141	}
142	return style
143}
144
145// renderAssistantMessage renders assistant messages with optional footer information.
146// Shows model name, response time, and finish reason when the message is complete.
147func (m *messageCmp) renderAssistantMessage() string {
148	t := styles.CurrentTheme()
149	parts := []string{
150		m.markdownContent(),
151	}
152
153	finished := m.message.IsFinished()
154	finishData := m.message.FinishPart()
155	// Only show the footer if the message is not a tool call
156	if finished && finishData.Reason != message.FinishReasonToolUse {
157		infoMsg := ""
158		switch finishData.Reason {
159		case message.FinishReasonEndTurn:
160			finishTime := time.Unix(finishData.Time, 0)
161			duration := finishTime.Sub(m.lastUserMessageTime)
162			infoMsg = duration.String()
163		case message.FinishReasonCanceled:
164			infoMsg = "canceled"
165		case message.FinishReasonError:
166			infoMsg = "error"
167		case message.FinishReasonPermissionDenied:
168			infoMsg = "permission denied"
169		}
170		assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
171		parts = append(parts, core.Section(assistant, m.textWidth()))
172	}
173
174	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
175	return m.style().Render(joined)
176}
177
178// renderUserMessage renders user messages with file attachments.
179// Displays message content and any attached files with appropriate icons.
180func (m *messageCmp) renderUserMessage() string {
181	t := styles.CurrentTheme()
182	parts := []string{
183		m.markdownContent(),
184	}
185	attachmentStyles := t.S().Text.
186		MarginLeft(1).
187		Background(t.BgSubtle)
188	attachments := []string{}
189	for _, attachment := range m.message.BinaryContent() {
190		file := filepath.Base(attachment.Path)
191		var filename string
192		if len(file) > 10 {
193			filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
194		} else {
195			filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
196		}
197		attachments = append(attachments, attachmentStyles.Render(filename))
198	}
199	if len(attachments) > 0 {
200		parts = append(parts, "", strings.Join(attachments, ""))
201	}
202	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
203	return m.style().MarginBottom(1).Render(joined)
204}
205
206// toMarkdown converts text content to rendered markdown using the configured renderer
207func (m *messageCmp) toMarkdown(content string) string {
208	r := styles.GetMarkdownRenderer(m.textWidth())
209	rendered, _ := r.Render(content)
210	return strings.TrimSuffix(rendered, "\n")
211}
212
213// markdownContent processes the message content and handles special states.
214// Returns appropriate content for thinking, finished, and error states.
215func (m *messageCmp) markdownContent() string {
216	content := m.message.Content().String()
217	if m.message.Role == message.Assistant {
218		thinking := m.message.IsThinking()
219		finished := m.message.IsFinished()
220		finishedData := m.message.FinishPart()
221		if thinking {
222			// Handle the thinking state
223			// TODO: maybe add the thinking content if available later.
224			content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
225		} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
226			// Sometimes the LLMs respond with no content when they think the previous tool result
227			//  provides the requested question
228			content = "*Finished without output*"
229		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
230			content = "*Canceled*"
231		}
232	}
233	return m.toMarkdown(content)
234}
235
236// shouldSpin determines whether the message should show a loading animation.
237// Only assistant messages without content that aren't finished should spin.
238func (m *messageCmp) shouldSpin() bool {
239	if m.message.Role != message.Assistant {
240		return false
241	}
242
243	if m.message.IsFinished() {
244		return false
245	}
246
247	if m.message.Content().Text != "" {
248		return false
249	}
250	return true
251}
252
253// Focus management methods
254
255// Blur removes focus from the message component
256func (m *messageCmp) Blur() tea.Cmd {
257	m.focused = false
258	return nil
259}
260
261// Focus sets focus on the message component
262func (m *messageCmp) Focus() tea.Cmd {
263	m.focused = true
264	return nil
265}
266
267// IsFocused returns whether the message component is currently focused
268func (m *messageCmp) IsFocused() bool {
269	return m.focused
270}
271
272// Size management methods
273
274// GetSize returns the current dimensions of the message component
275func (m *messageCmp) GetSize() (int, int) {
276	return m.width, 0
277}
278
279// SetSize updates the width of the message component for text wrapping
280func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
281	// For better readability, we limit the width to a maximum of 120 characters
282	m.width = min(width, 120)
283	return nil
284}
285
286// Spinning returns whether the message is currently showing a loading animation
287func (m *messageCmp) Spinning() bool {
288	return m.spinning
289}