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