1package chat
2
3import (
4 "context"
5 "time"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/opencode-ai/opencode/internal/app"
11 "github.com/opencode-ai/opencode/internal/llm/agent"
12 "github.com/opencode-ai/opencode/internal/message"
13 "github.com/opencode-ai/opencode/internal/pubsub"
14 "github.com/opencode-ai/opencode/internal/session"
15 "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
16 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
17 "github.com/opencode-ai/opencode/internal/tui/layout"
18 "github.com/opencode-ai/opencode/internal/tui/util"
19)
20
21type SendMsg struct {
22 Text string
23 Attachments []message.Attachment
24}
25
26type SessionSelectedMsg = session.Session
27
28type SessionClearedMsg struct{}
29
30const (
31 NotFound = -1
32)
33
34// MessageListCmp represents a component that displays a list of chat messages
35// with support for real-time updates and session management.
36type MessageListCmp interface {
37 util.Model
38 layout.Sizeable
39}
40
41// messageListCmp implements MessageListCmp, providing a virtualized list
42// of chat messages with support for tool calls, real-time updates, and
43// session switching.
44type messageListCmp struct {
45 app *app.App
46 width, height int
47 session session.Session
48 listCmp list.ListModel
49
50 lastUserMessageTime int64
51}
52
53// NewMessagesListCmp creates a new message list component with custom keybindings
54// and reverse ordering (newest messages at bottom).
55func NewMessagesListCmp(app *app.App) MessageListCmp {
56 defaultKeymaps := list.DefaultKeyMap()
57 defaultKeymaps.Up.SetEnabled(false)
58 defaultKeymaps.Down.SetEnabled(false)
59 defaultKeymaps.NDown = key.NewBinding(
60 key.WithKeys("ctrl+j"),
61 )
62 defaultKeymaps.NUp = key.NewBinding(
63 key.WithKeys("ctrl+k"),
64 )
65 defaultKeymaps.Home = key.NewBinding(
66 key.WithKeys("ctrl+shift+up"),
67 )
68 defaultKeymaps.End = key.NewBinding(
69 key.WithKeys("ctrl+shift+down"),
70 )
71 return &messageListCmp{
72 app: app,
73 listCmp: list.New(
74 list.WithGapSize(1),
75 list.WithReverse(true),
76 list.WithKeyMap(defaultKeymaps),
77 ),
78 }
79}
80
81// Init initializes the component (no initialization needed).
82func (m *messageListCmp) Init() tea.Cmd {
83 return nil
84}
85
86// Update handles incoming messages and updates the component state.
87func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 switch msg := msg.(type) {
89 case SessionSelectedMsg:
90 if msg.ID != m.session.ID {
91 cmd := m.SetSession(msg)
92 return m, cmd
93 }
94 return m, nil
95 case SessionClearedMsg:
96 m.session = session.Session{}
97 return m, m.listCmp.SetItems([]util.Model{})
98
99 case pubsub.Event[message.Message]:
100 cmd := m.handleMessageEvent(msg)
101 return m, cmd
102 default:
103 var cmds []tea.Cmd
104 u, cmd := m.listCmp.Update(msg)
105 m.listCmp = u.(list.ListModel)
106 cmds = append(cmds, cmd)
107 return m, tea.Batch(cmds...)
108 }
109}
110
111// View renders the message list or an initial screen if empty.
112func (m *messageListCmp) View() tea.View {
113 return tea.NewView(
114 lipgloss.JoinVertical(
115 lipgloss.Left,
116 m.listCmp.View().String(),
117 ),
118 )
119}
120
121// handleChildSession handles messages from child sessions (agent tools).
122func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
123 var cmds []tea.Cmd
124 if len(event.Payload.ToolCalls()) == 0 {
125 return nil
126 }
127 items := m.listCmp.Items()
128 toolCallInx := NotFound
129 var toolCall messages.ToolCallCmp
130 for i := len(items) - 1; i >= 0; i-- {
131 if msg, ok := items[i].(messages.ToolCallCmp); ok {
132 if msg.GetToolCall().ID == event.Payload.SessionID {
133 toolCallInx = i
134 toolCall = msg
135 }
136 }
137 }
138 if toolCallInx == NotFound {
139 return nil
140 }
141 nestedToolCalls := toolCall.GetNestedToolCalls()
142 for _, tc := range event.Payload.ToolCalls() {
143 found := false
144 for existingInx, existingTC := range nestedToolCalls {
145 if existingTC.GetToolCall().ID == tc.ID {
146 nestedToolCalls[existingInx].SetToolCall(tc)
147 found = true
148 break
149 }
150 }
151 if !found {
152 nestedCall := messages.NewToolCallCmp(
153 event.Payload.ID,
154 tc,
155 messages.WithToolCallNested(true),
156 )
157 cmds = append(cmds, nestedCall.Init())
158 nestedToolCalls = append(
159 nestedToolCalls,
160 nestedCall,
161 )
162 }
163 }
164 toolCall.SetNestedToolCalls(nestedToolCalls)
165 m.listCmp.UpdateItem(
166 toolCallInx,
167 toolCall,
168 )
169 return tea.Batch(cmds...)
170}
171
172// handleMessageEvent processes different types of message events (created/updated).
173func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
174 switch event.Type {
175 case pubsub.CreatedEvent:
176 if event.Payload.SessionID != m.session.ID {
177 return m.handleChildSession(event)
178 }
179 if m.messageExists(event.Payload.ID) {
180 return nil
181 }
182 return m.handleNewMessage(event.Payload)
183 case pubsub.UpdatedEvent:
184 if event.Payload.SessionID != m.session.ID {
185 return m.handleChildSession(event)
186 }
187 return m.handleUpdateAssistantMessage(event.Payload)
188 }
189 return nil
190}
191
192// messageExists checks if a message with the given ID already exists in the list.
193func (m *messageListCmp) messageExists(messageID string) bool {
194 items := m.listCmp.Items()
195 // Search backwards as new messages are more likely to be at the end
196 for i := len(items) - 1; i >= 0; i-- {
197 if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
198 return true
199 }
200 }
201 return false
202}
203
204// handleNewMessage routes new messages to appropriate handlers based on role.
205func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
206 switch msg.Role {
207 case message.User:
208 return m.handleNewUserMessage(msg)
209 case message.Assistant:
210 return m.handleNewAssistantMessage(msg)
211 case message.Tool:
212 return m.handleToolMessage(msg)
213 }
214 return nil
215}
216
217// handleNewUserMessage adds a new user message to the list and updates the timestamp.
218func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
219 m.lastUserMessageTime = msg.CreatedAt
220 return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
221}
222
223// handleToolMessage updates existing tool calls with their results.
224func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
225 items := m.listCmp.Items()
226 for _, tr := range msg.ToolResults() {
227 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
228 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
229 toolCall.SetToolResult(tr)
230 m.listCmp.UpdateItem(toolCallIndex, toolCall)
231 }
232 }
233 return nil
234}
235
236// findToolCallByID searches for a tool call with the specified ID.
237// Returns the index if found, NotFound otherwise.
238func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
239 // Search backwards as tool calls are more likely to be recent
240 for i := len(items) - 1; i >= 0; i-- {
241 if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
242 return i
243 }
244 }
245 return NotFound
246}
247
248// handleUpdateAssistantMessage processes updates to assistant messages,
249// managing both message content and associated tool calls.
250func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
251 var cmds []tea.Cmd
252 items := m.listCmp.Items()
253
254 // Find existing assistant message and tool calls for this message
255 assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
256
257 // Handle assistant message content
258 if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
259 cmds = append(cmds, cmd)
260 }
261
262 // Handle tool calls
263 if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
264 cmds = append(cmds, cmd)
265 }
266
267 return tea.Batch(cmds...)
268}
269
270// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
271func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
272 assistantIndex := NotFound
273 toolCalls := make(map[int]messages.ToolCallCmp)
274
275 // Search backwards as messages are more likely to be at the end
276 for i := len(items) - 1; i >= 0; i-- {
277 item := items[i]
278 if asMsg, ok := item.(messages.MessageCmp); ok {
279 if asMsg.GetMessage().ID == messageID {
280 assistantIndex = i
281 }
282 } else if tc, ok := item.(messages.ToolCallCmp); ok {
283 if tc.ParentMessageId() == messageID {
284 toolCalls[i] = tc
285 }
286 }
287 }
288
289 return assistantIndex, toolCalls
290}
291
292// updateAssistantMessageContent updates or removes the assistant message based on content.
293func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
294 if assistantIndex == NotFound {
295 return nil
296 }
297
298 shouldShowMessage := m.shouldShowAssistantMessage(msg)
299 hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
300
301 if shouldShowMessage {
302 m.listCmp.UpdateItem(
303 assistantIndex,
304 messages.NewMessageCmp(
305 msg,
306 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
307 ),
308 )
309 } else if hasToolCallsOnly {
310 m.listCmp.DeleteItem(assistantIndex)
311 }
312
313 return nil
314}
315
316// shouldShowAssistantMessage determines if an assistant message should be displayed.
317func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
318 return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
319}
320
321// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
322func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
323 var cmds []tea.Cmd
324
325 for _, tc := range msg.ToolCalls() {
326 if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
327 cmds = append(cmds, cmd)
328 }
329 }
330
331 return tea.Batch(cmds...)
332}
333
334// updateOrAddToolCall updates an existing tool call or adds a new one.
335func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
336 // Try to find existing tool call
337 for index, existingTC := range existingToolCalls {
338 if tc.ID == existingTC.GetToolCall().ID {
339 existingTC.SetToolCall(tc)
340 m.listCmp.UpdateItem(index, existingTC)
341 return nil
342 }
343 }
344
345 // Add new tool call if not found
346 return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
347}
348
349// handleNewAssistantMessage processes new assistant messages and their tool calls.
350func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
351 var cmds []tea.Cmd
352
353 // Add assistant message if it should be displayed
354 if m.shouldShowAssistantMessage(msg) {
355 cmd := m.listCmp.AppendItem(
356 messages.NewMessageCmp(
357 msg,
358 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
359 ),
360 )
361 cmds = append(cmds, cmd)
362 }
363
364 // Add tool calls
365 for _, tc := range msg.ToolCalls() {
366 cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
367 cmds = append(cmds, cmd)
368 }
369
370 return tea.Batch(cmds...)
371}
372
373// SetSession loads and displays messages for a new session.
374func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
375 if m.session.ID == session.ID {
376 return nil
377 }
378
379 m.session = session
380 sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
381 if err != nil {
382 return util.ReportError(err)
383 }
384
385 if len(sessionMessages) == 0 {
386 return m.listCmp.SetItems([]util.Model{})
387 }
388
389 // Initialize with first message timestamp
390 m.lastUserMessageTime = sessionMessages[0].CreatedAt
391
392 // Build tool result map for efficient lookup
393 toolResultMap := m.buildToolResultMap(sessionMessages)
394
395 // Convert messages to UI components
396 uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
397
398 return m.listCmp.SetItems(uiMessages)
399}
400
401// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
402func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
403 toolResultMap := make(map[string]message.ToolResult)
404 for _, msg := range messages {
405 for _, tr := range msg.ToolResults() {
406 toolResultMap[tr.ToolCallID] = tr
407 }
408 }
409 return toolResultMap
410}
411
412// convertMessagesToUI converts database messages to UI components.
413func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
414 uiMessages := make([]util.Model, 0)
415
416 for _, msg := range sessionMessages {
417 switch msg.Role {
418 case message.User:
419 m.lastUserMessageTime = msg.CreatedAt
420 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
421 case message.Assistant:
422 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
423 }
424 }
425
426 return uiMessages
427}
428
429// convertAssistantMessage converts an assistant message and its tool calls to UI components.
430func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
431 var uiMessages []util.Model
432
433 // Add assistant message if it should be displayed
434 if m.shouldShowAssistantMessage(msg) {
435 uiMessages = append(
436 uiMessages,
437 messages.NewMessageCmp(
438 msg,
439 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
440 ),
441 )
442 }
443
444 // Add tool calls with their results and status
445 for _, tc := range msg.ToolCalls() {
446 options := m.buildToolCallOptions(tc, msg, toolResultMap)
447 uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
448 // If this tool call is the agent tool, fetch nested tool calls
449 if tc.Name == agent.AgentToolName {
450 nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
451 nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
452 nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
453 for _, nestedMsg := range nestedUIMessages {
454 if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
455 toolCall.SetIsNested(true)
456 nestedToolCalls = append(nestedToolCalls, toolCall)
457 }
458 }
459 uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
460 }
461 }
462
463 return uiMessages
464}
465
466// buildToolCallOptions creates options for tool call components based on results and status.
467func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
468 var options []messages.ToolCallOption
469
470 // Add tool result if available
471 if tr, ok := toolResultMap[tc.ID]; ok {
472 options = append(options, messages.WithToolCallResult(tr))
473 }
474
475 // Add cancelled status if applicable
476 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
477 options = append(options, messages.WithToolCallCancelled())
478 }
479
480 return options
481}
482
483// GetSize returns the current width and height of the component.
484func (m *messageListCmp) GetSize() (int, int) {
485 return m.width, m.height
486}
487
488// SetSize updates the component dimensions and propagates to the list component.
489func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
490 m.width = width
491 m.height = height - 1
492 return m.listCmp.SetSize(width, height-1)
493}