message_v2.go

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