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
 22type MessageCmp interface {
 23	util.Model
 24	layout.Sizeable
 25	layout.Focusable
 26	GetMessage() message.Message
 27	Spinning() bool
 28}
 29
 30type messageCmp struct {
 31	width   int
 32	focused bool
 33
 34	// Used for agent and user messages
 35	message             message.Message
 36	spinning            bool
 37	anim                util.Model
 38	lastUserMessageTime time.Time
 39}
 40type MessageOption func(*messageCmp)
 41
 42func WithLastUserMessageTime(t time.Time) MessageOption {
 43	return func(m *messageCmp) {
 44		m.lastUserMessageTime = t
 45	}
 46}
 47
 48func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
 49	m := &messageCmp{
 50		message: msg,
 51		anim:    anim.New(15, ""),
 52	}
 53	for _, opt := range opts {
 54		opt(m)
 55	}
 56	return m
 57}
 58
 59func (m *messageCmp) Init() tea.Cmd {
 60	m.spinning = m.shouldSpin()
 61	if m.spinning {
 62		return m.anim.Init()
 63	}
 64	return nil
 65}
 66
 67func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 68	u, cmd := m.anim.Update(msg)
 69	m.anim = u.(util.Model)
 70	return m, cmd
 71}
 72
 73func (m *messageCmp) View() string {
 74	if m.spinning {
 75		return m.style().PaddingLeft(1).Render(m.anim.View())
 76	}
 77	if m.message.ID != "" {
 78		// this is a user or assistant message
 79		switch m.message.Role {
 80		case message.User:
 81			return m.renderUserMessage()
 82		default:
 83			return m.renderAssistantMessage()
 84		}
 85	}
 86	return "Unknown Message"
 87}
 88
 89// GetMessage implements MessageCmp.
 90func (m *messageCmp) GetMessage() message.Message {
 91	return m.message
 92}
 93
 94func (m *messageCmp) textWidth() int {
 95	return m.width - 1 // take into account the border
 96}
 97
 98func (msg *messageCmp) style() lipgloss.Style {
 99	t := theme.CurrentTheme()
100	var borderColor color.Color
101	borderStyle := lipgloss.NormalBorder()
102	if msg.focused {
103		borderStyle = lipgloss.DoubleBorder()
104	}
105
106	switch msg.message.Role {
107	case message.User:
108		borderColor = t.Secondary()
109	case message.Assistant:
110		borderColor = t.Primary()
111	default:
112		// Tool call
113		borderColor = t.TextMuted()
114	}
115
116	return styles.BaseStyle().
117		BorderLeft(true).
118		Foreground(t.TextMuted()).
119		BorderForeground(borderColor).
120		BorderStyle(borderStyle)
121}
122
123func (m *messageCmp) renderAssistantMessage() string {
124	parts := []string{
125		m.markdownContent(),
126	}
127
128	finished := m.message.IsFinished()
129	finishData := m.message.FinishPart()
130	// Only show the footer if the message is not a tool call
131	if finished && finishData.Reason != message.FinishReasonToolUse {
132		infoMsg := ""
133		switch finishData.Reason {
134		case message.FinishReasonEndTurn:
135			finishTime := time.Unix(finishData.Time, 0)
136			duration := finishTime.Sub(m.lastUserMessageTime)
137			infoMsg = duration.String()
138		case message.FinishReasonCanceled:
139			infoMsg = "canceled"
140		case message.FinishReasonError:
141			infoMsg = "error"
142		case message.FinishReasonPermissionDenied:
143			infoMsg = "permission denied"
144		}
145		parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
146	}
147
148	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
149	return m.style().Render(joined)
150}
151
152func (m *messageCmp) renderUserMessage() string {
153	t := theme.CurrentTheme()
154	parts := []string{
155		m.markdownContent(),
156	}
157	attachmentStyles := styles.BaseStyle().
158		MarginLeft(1).
159		Background(t.BackgroundSecondary()).
160		Foreground(t.Text())
161	attachments := []string{}
162	for _, attachment := range m.message.BinaryContent() {
163		file := filepath.Base(attachment.Path)
164		var filename string
165		if len(file) > 10 {
166			filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
167		} else {
168			filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
169		}
170		attachments = append(attachments, attachmentStyles.Render(filename))
171	}
172	if len(attachments) > 0 {
173		parts = append(parts, "", strings.Join(attachments, ""))
174	}
175	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
176	return m.style().Render(joined)
177}
178
179func (m *messageCmp) toMarkdown(content string) string {
180	r := styles.GetMarkdownRenderer(m.textWidth())
181	rendered, _ := r.Render(content)
182	return strings.TrimSuffix(rendered, "\n")
183}
184
185func (m *messageCmp) markdownContent() string {
186	content := m.message.Content().String()
187	if m.message.Role == message.Assistant {
188		thinking := m.message.IsThinking()
189		finished := m.message.IsFinished()
190		finishedData := m.message.FinishPart()
191		if thinking {
192			// Handle the thinking state
193			// TODO: maybe add the thinking content if available later.
194			content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
195		} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
196			// Sometimes the LLMs respond with no content when they think the previous tool result
197			//  provides the requested question
198			content = "*Finished without output*"
199		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
200			content = "*Canceled*"
201		}
202	}
203	return m.toMarkdown(content)
204}
205
206func (m *messageCmp) shouldSpin() bool {
207	if m.message.Role != message.Assistant {
208		return false
209	}
210
211	if m.message.IsFinished() {
212		return false
213	}
214
215	if m.message.Content().Text != "" {
216		return false
217	}
218	return true
219}
220
221// Blur implements MessageModel.
222func (m *messageCmp) Blur() tea.Cmd {
223	m.focused = false
224	return nil
225}
226
227// Focus implements MessageModel.
228func (m *messageCmp) Focus() tea.Cmd {
229	m.focused = true
230	return nil
231}
232
233// IsFocused implements MessageModel.
234func (m *messageCmp) IsFocused() bool {
235	return m.focused
236}
237
238func (m *messageCmp) GetSize() (int, int) {
239	return m.width, 0
240}
241
242func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
243	m.width = width
244	return nil
245}
246
247// Spinning implements MessageCmp.
248func (m *messageCmp) Spinning() bool {
249	return m.spinning
250}