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