messages.go

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