messages.go

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