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