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