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/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 - 1 // take into account the border
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	var borderColor color.Color
128	borderStyle := lipgloss.NormalBorder()
129	if msg.focused {
130		borderStyle = lipgloss.DoubleBorder()
131	}
132
133	switch msg.message.Role {
134	case message.User:
135		borderColor = t.Secondary
136	case message.Assistant:
137		borderColor = t.Primary
138	default:
139		// Tool call
140		borderColor = t.BgSubtle
141	}
142
143	return t.S().Muted.
144		BorderLeft(true).
145		BorderForeground(borderColor).
146		BorderStyle(borderStyle)
147}
148
149// renderAssistantMessage renders assistant messages with optional footer information.
150// Shows model name, response time, and finish reason when the message is complete.
151func (m *messageCmp) renderAssistantMessage() string {
152	parts := []string{
153		m.markdownContent(),
154	}
155
156	finished := m.message.IsFinished()
157	finishData := m.message.FinishPart()
158	// Only show the footer if the message is not a tool call
159	if finished && finishData.Reason != message.FinishReasonToolUse {
160		infoMsg := ""
161		switch finishData.Reason {
162		case message.FinishReasonEndTurn:
163			finishTime := time.Unix(finishData.Time, 0)
164			duration := finishTime.Sub(m.lastUserMessageTime)
165			infoMsg = duration.String()
166		case message.FinishReasonCanceled:
167			infoMsg = "canceled"
168		case message.FinishReasonError:
169			infoMsg = "error"
170		case message.FinishReasonPermissionDenied:
171			infoMsg = "permission denied"
172		}
173		parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
174	}
175
176	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
177	return m.style().Render(joined)
178}
179
180// renderUserMessage renders user messages with file attachments.
181// Displays message content and any attached files with appropriate icons.
182func (m *messageCmp) renderUserMessage() string {
183	t := styles.CurrentTheme()
184	parts := []string{
185		m.markdownContent(),
186	}
187	attachmentStyles := t.S().Text.
188		MarginLeft(1).
189		Background(t.BgSubtle)
190	attachments := []string{}
191	for _, attachment := range m.message.BinaryContent() {
192		file := filepath.Base(attachment.Path)
193		var filename string
194		if len(file) > 10 {
195			filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
196		} else {
197			filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
198		}
199		attachments = append(attachments, attachmentStyles.Render(filename))
200	}
201	if len(attachments) > 0 {
202		parts = append(parts, "", strings.Join(attachments, ""))
203	}
204	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
205	return m.style().Render(joined)
206}
207
208// toMarkdown converts text content to rendered markdown using the configured renderer
209func (m *messageCmp) toMarkdown(content string) string {
210	r := styles.GetMarkdownRenderer(m.textWidth())
211	rendered, _ := r.Render(content)
212	return strings.TrimSuffix(rendered, "\n")
213}
214
215// markdownContent processes the message content and handles special states.
216// Returns appropriate content for thinking, finished, and error states.
217func (m *messageCmp) markdownContent() string {
218	content := m.message.Content().String()
219	if m.message.Role == message.Assistant {
220		thinking := m.message.IsThinking()
221		finished := m.message.IsFinished()
222		finishedData := m.message.FinishPart()
223		if thinking {
224			// Handle the thinking state
225			// TODO: maybe add the thinking content if available later.
226			content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
227		} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
228			// Sometimes the LLMs respond with no content when they think the previous tool result
229			//  provides the requested question
230			content = "*Finished without output*"
231		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
232			content = "*Canceled*"
233		}
234	}
235	return m.toMarkdown(content)
236}
237
238// shouldSpin determines whether the message should show a loading animation.
239// Only assistant messages without content that aren't finished should spin.
240func (m *messageCmp) shouldSpin() bool {
241	if m.message.Role != message.Assistant {
242		return false
243	}
244
245	if m.message.IsFinished() {
246		return false
247	}
248
249	if m.message.Content().Text != "" {
250		return false
251	}
252	return true
253}
254
255// Focus management methods
256
257// Blur removes focus from the message component
258func (m *messageCmp) Blur() tea.Cmd {
259	m.focused = false
260	return nil
261}
262
263// Focus sets focus on the message component
264func (m *messageCmp) Focus() tea.Cmd {
265	m.focused = true
266	return nil
267}
268
269// IsFocused returns whether the message component is currently focused
270func (m *messageCmp) IsFocused() bool {
271	return m.focused
272}
273
274// Size management methods
275
276// GetSize returns the current dimensions of the message component
277func (m *messageCmp) GetSize() (int, int) {
278	return m.width, 0
279}
280
281// SetSize updates the width of the message component for text wrapping
282func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
283	m.width = width
284	return nil
285}
286
287// Spinning returns whether the message is currently showing a loading animation
288func (m *messageCmp) Spinning() bool {
289	return m.spinning
290}