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}