messages.go

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