1package chat
2
3import (
4 "context"
5 "fmt"
6 "math"
7
8 "github.com/charmbracelet/bubbles/key"
9 "github.com/charmbracelet/bubbles/spinner"
10 "github.com/charmbracelet/bubbles/viewport"
11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/opencode-ai/opencode/internal/app"
14 "github.com/opencode-ai/opencode/internal/message"
15 "github.com/opencode-ai/opencode/internal/pubsub"
16 "github.com/opencode-ai/opencode/internal/session"
17 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
18 "github.com/opencode-ai/opencode/internal/tui/styles"
19 "github.com/opencode-ai/opencode/internal/tui/theme"
20 "github.com/opencode-ai/opencode/internal/tui/util"
21)
22
23type cacheItem struct {
24 width int
25 content []uiMessage
26}
27type messagesCmp struct {
28 app *app.App
29 width, height int
30 viewport viewport.Model
31 session session.Session
32 messages []message.Message
33 uiMessages []uiMessage
34 currentMsgID string
35 cachedContent map[string]cacheItem
36 spinner spinner.Model
37 rendering bool
38 attachments viewport.Model
39}
40type renderFinishedMsg struct{}
41
42type MessageKeys struct {
43 PageDown key.Binding
44 PageUp key.Binding
45 HalfPageUp key.Binding
46 HalfPageDown key.Binding
47}
48
49var messageKeys = MessageKeys{
50 PageDown: key.NewBinding(
51 key.WithKeys("pgdown"),
52 key.WithHelp("f/pgdn", "page down"),
53 ),
54 PageUp: key.NewBinding(
55 key.WithKeys("pgup"),
56 key.WithHelp("b/pgup", "page up"),
57 ),
58 HalfPageUp: key.NewBinding(
59 key.WithKeys("ctrl+u"),
60 key.WithHelp("ctrl+u", "½ page up"),
61 ),
62 HalfPageDown: key.NewBinding(
63 key.WithKeys("ctrl+d", "ctrl+d"),
64 key.WithHelp("ctrl+d", "½ page down"),
65 ),
66}
67
68func (m *messagesCmp) Init() tea.Cmd {
69 return tea.Batch(m.viewport.Init(), m.spinner.Tick)
70}
71
72func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73 var cmds []tea.Cmd
74 switch msg := msg.(type) {
75 case dialog.ThemeChangedMsg:
76 m.rerender()
77 return m, nil
78 case SessionSelectedMsg:
79 if msg.ID != m.session.ID {
80 cmd := m.SetSession(msg)
81 return m, cmd
82 }
83 return m, nil
84 case SessionClearedMsg:
85 m.session = session.Session{}
86 m.messages = make([]message.Message, 0)
87 m.currentMsgID = ""
88 m.rendering = false
89 return m, nil
90
91 case tea.KeyMsg:
92 if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
93 key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
94 u, cmd := m.viewport.Update(msg)
95 m.viewport = u
96 cmds = append(cmds, cmd)
97 }
98
99 case renderFinishedMsg:
100 m.rendering = false
101 m.viewport.GotoBottom()
102 case pubsub.Event[session.Session]:
103 if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.session.ID {
104 m.session = msg.Payload
105 if m.session.SummaryMessageID == m.currentMsgID {
106 delete(m.cachedContent, m.currentMsgID)
107 m.renderView()
108 }
109 }
110 case pubsub.Event[message.Message]:
111 needsRerender := false
112 if msg.Type == pubsub.CreatedEvent {
113 if msg.Payload.SessionID == m.session.ID {
114
115 messageExists := false
116 for _, v := range m.messages {
117 if v.ID == msg.Payload.ID {
118 messageExists = true
119 break
120 }
121 }
122
123 if !messageExists {
124 if len(m.messages) > 0 {
125 lastMsgID := m.messages[len(m.messages)-1].ID
126 delete(m.cachedContent, lastMsgID)
127 }
128
129 m.messages = append(m.messages, msg.Payload)
130 delete(m.cachedContent, m.currentMsgID)
131 m.currentMsgID = msg.Payload.ID
132 needsRerender = true
133 }
134 }
135 // There are tool calls from the child task
136 for _, v := range m.messages {
137 for _, c := range v.ToolCalls() {
138 if c.ID == msg.Payload.SessionID {
139 delete(m.cachedContent, v.ID)
140 needsRerender = true
141 }
142 }
143 }
144 } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
145 for i, v := range m.messages {
146 if v.ID == msg.Payload.ID {
147 m.messages[i] = msg.Payload
148 delete(m.cachedContent, msg.Payload.ID)
149 needsRerender = true
150 break
151 }
152 }
153 }
154 if needsRerender {
155 m.renderView()
156 if len(m.messages) > 0 {
157 if (msg.Type == pubsub.CreatedEvent) ||
158 (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
159 m.viewport.GotoBottom()
160 }
161 }
162 }
163 }
164
165 spinner, cmd := m.spinner.Update(msg)
166 m.spinner = spinner
167 cmds = append(cmds, cmd)
168 return m, tea.Batch(cmds...)
169}
170
171func (m *messagesCmp) IsAgentWorking() bool {
172 return m.app.CoderAgent.IsSessionBusy(m.session.ID)
173}
174
175func formatTimeDifference(unixTime1, unixTime2 int64) string {
176 diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
177
178 if diffSeconds < 60 {
179 return fmt.Sprintf("%.1fs", diffSeconds)
180 }
181
182 minutes := int(diffSeconds / 60)
183 seconds := int(diffSeconds) % 60
184 return fmt.Sprintf("%dm%ds", minutes, seconds)
185}
186
187func (m *messagesCmp) renderView() {
188 m.uiMessages = make([]uiMessage, 0)
189 pos := 0
190 baseStyle := styles.BaseStyle()
191
192 if m.width == 0 {
193 return
194 }
195 for inx, msg := range m.messages {
196 switch msg.Role {
197 case message.User:
198 if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
199 m.uiMessages = append(m.uiMessages, cache.content...)
200 continue
201 }
202 userMsg := renderUserMessage(
203 msg,
204 msg.ID == m.currentMsgID,
205 m.width,
206 pos,
207 )
208 m.uiMessages = append(m.uiMessages, userMsg)
209 m.cachedContent[msg.ID] = cacheItem{
210 width: m.width,
211 content: []uiMessage{userMsg},
212 }
213 pos += userMsg.height + 1 // + 1 for spacing
214 case message.Assistant:
215 if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
216 m.uiMessages = append(m.uiMessages, cache.content...)
217 continue
218 }
219 isSummary := m.session.SummaryMessageID == msg.ID
220
221 assistantMessages := renderAssistantMessage(
222 msg,
223 inx,
224 m.messages,
225 m.app.Messages,
226 m.currentMsgID,
227 isSummary,
228 m.width,
229 pos,
230 )
231 for _, msg := range assistantMessages {
232 m.uiMessages = append(m.uiMessages, msg)
233 pos += msg.height + 1 // + 1 for spacing
234 }
235 m.cachedContent[msg.ID] = cacheItem{
236 width: m.width,
237 content: assistantMessages,
238 }
239 }
240 }
241
242 messages := make([]string, 0)
243 for _, v := range m.uiMessages {
244 messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
245 baseStyle.
246 Width(m.width).
247 Render(
248 "",
249 ),
250 )
251 }
252
253 m.viewport.SetContent(
254 baseStyle.
255 Width(m.width).
256 Render(
257 lipgloss.JoinVertical(
258 lipgloss.Top,
259 messages...,
260 ),
261 ),
262 )
263}
264
265func (m *messagesCmp) View() string {
266 baseStyle := styles.BaseStyle()
267
268 if m.rendering {
269 return baseStyle.
270 Width(m.width).
271 Render(
272 lipgloss.JoinVertical(
273 lipgloss.Top,
274 "Loading...",
275 m.working(),
276 m.help(),
277 ),
278 )
279 }
280 if len(m.messages) == 0 {
281 content := baseStyle.
282 Width(m.width).
283 Height(m.height - 1).
284 Render(
285 m.initialScreen(),
286 )
287
288 return baseStyle.
289 Width(m.width).
290 Render(
291 lipgloss.JoinVertical(
292 lipgloss.Top,
293 content,
294 "",
295 m.help(),
296 ),
297 )
298 }
299
300 return baseStyle.
301 Width(m.width).
302 Render(
303 lipgloss.JoinVertical(
304 lipgloss.Top,
305 m.viewport.View(),
306 m.working(),
307 m.help(),
308 ),
309 )
310}
311
312func hasToolsWithoutResponse(messages []message.Message) bool {
313 toolCalls := make([]message.ToolCall, 0)
314 toolResults := make([]message.ToolResult, 0)
315 for _, m := range messages {
316 toolCalls = append(toolCalls, m.ToolCalls()...)
317 toolResults = append(toolResults, m.ToolResults()...)
318 }
319
320 for _, v := range toolCalls {
321 found := false
322 for _, r := range toolResults {
323 if v.ID == r.ToolCallID {
324 found = true
325 break
326 }
327 }
328 if !found && v.Finished {
329 return true
330 }
331 }
332 return false
333}
334
335func hasUnfinishedToolCalls(messages []message.Message) bool {
336 toolCalls := make([]message.ToolCall, 0)
337 for _, m := range messages {
338 toolCalls = append(toolCalls, m.ToolCalls()...)
339 }
340 for _, v := range toolCalls {
341 if !v.Finished {
342 return true
343 }
344 }
345 return false
346}
347
348func (m *messagesCmp) working() string {
349 text := ""
350 if m.IsAgentWorking() && len(m.messages) > 0 {
351 t := theme.CurrentTheme()
352 baseStyle := styles.BaseStyle()
353
354 task := "Thinking..."
355 lastMessage := m.messages[len(m.messages)-1]
356 if hasToolsWithoutResponse(m.messages) {
357 task = "Waiting for tool response..."
358 } else if hasUnfinishedToolCalls(m.messages) {
359 task = "Building tool call..."
360 } else if !lastMessage.IsFinished() {
361 task = "Generating..."
362 }
363 if task != "" {
364 text += baseStyle.
365 Width(m.width).
366 Foreground(t.Primary()).
367 Bold(true).
368 Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
369 }
370 }
371 return text
372}
373
374func (m *messagesCmp) help() string {
375 t := theme.CurrentTheme()
376 baseStyle := styles.BaseStyle()
377
378 text := ""
379
380 if m.app.CoderAgent.IsBusy() {
381 text += lipgloss.JoinHorizontal(
382 lipgloss.Left,
383 baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
384 baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
385 baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"),
386 )
387 } else {
388 text += lipgloss.JoinHorizontal(
389 lipgloss.Left,
390 baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
391 baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
392 baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
393 baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
394 baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
395 baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"),
396 )
397 }
398 return baseStyle.
399 Width(m.width).
400 Render(text)
401}
402
403func (m *messagesCmp) initialScreen() string {
404 baseStyle := styles.BaseStyle()
405
406 return baseStyle.Width(m.width).Render(
407 lipgloss.JoinVertical(
408 lipgloss.Top,
409 header(m.width),
410 "",
411 lspsConfigured(m.width),
412 ),
413 )
414}
415
416func (m *messagesCmp) rerender() {
417 for _, msg := range m.messages {
418 delete(m.cachedContent, msg.ID)
419 }
420 m.renderView()
421}
422
423func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
424 if m.width == width && m.height == height {
425 return nil
426 }
427 m.width = width
428 m.height = height
429 m.viewport.Width = width
430 m.viewport.Height = height - 2
431 m.attachments.Width = width + 40
432 m.attachments.Height = 3
433 m.rerender()
434 return nil
435}
436
437func (m *messagesCmp) GetSize() (int, int) {
438 return m.width, m.height
439}
440
441func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
442 if m.session.ID == session.ID {
443 return nil
444 }
445 m.session = session
446 messages, err := m.app.Messages.List(context.Background(), session.ID)
447 if err != nil {
448 return util.ReportError(err)
449 }
450 m.messages = messages
451 if len(m.messages) > 0 {
452 m.currentMsgID = m.messages[len(m.messages)-1].ID
453 }
454 delete(m.cachedContent, m.currentMsgID)
455 m.rendering = true
456 return func() tea.Msg {
457 m.renderView()
458 return renderFinishedMsg{}
459 }
460}
461
462func (m *messagesCmp) BindingKeys() []key.Binding {
463 return []key.Binding{
464 m.viewport.KeyMap.PageDown,
465 m.viewport.KeyMap.PageUp,
466 m.viewport.KeyMap.HalfPageUp,
467 m.viewport.KeyMap.HalfPageDown,
468 }
469}
470
471func NewMessagesCmp(app *app.App) tea.Model {
472 s := spinner.New()
473 s.Spinner = spinner.Pulse
474 vp := viewport.New(0, 0)
475 attachmets := viewport.New(0, 0)
476 vp.KeyMap.PageUp = messageKeys.PageUp
477 vp.KeyMap.PageDown = messageKeys.PageDown
478 vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
479 vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
480 return &messagesCmp{
481 app: app,
482 cachedContent: make(map[string]cacheItem),
483 viewport: vp,
484 spinner: s,
485 attachments: attachmets,
486 }
487}