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