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/logging"
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).
109// TODO: update the agent tool message with the changes
110func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
111 // Implementation pending
112}
113
114// handleMessageEvent processes different types of message events (created/updated).
115func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
116 switch event.Type {
117 case pubsub.CreatedEvent:
118 if event.Payload.SessionID != m.session.ID {
119 m.handleChildSession(event)
120 return nil
121 }
122
123 if m.messageExists(event.Payload.ID) {
124 return nil
125 }
126
127 return m.handleNewMessage(event.Payload)
128 case pubsub.UpdatedEvent:
129 return m.handleUpdateAssistantMessage(event.Payload)
130 }
131 return nil
132}
133
134// messageExists checks if a message with the given ID already exists in the list.
135func (m *messageListCmp) messageExists(messageID string) bool {
136 items := m.listCmp.Items()
137 // Search backwards as new messages are more likely to be at the end
138 for i := len(items) - 1; i >= 0; i-- {
139 if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
140 return true
141 }
142 }
143 return false
144}
145
146// handleNewMessage routes new messages to appropriate handlers based on role.
147func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
148 switch msg.Role {
149 case message.User:
150 return m.handleNewUserMessage(msg)
151 case message.Assistant:
152 return m.handleNewAssistantMessage(msg)
153 case message.Tool:
154 return m.handleToolMessage(msg)
155 }
156 return nil
157}
158
159// handleNewUserMessage adds a new user message to the list and updates the timestamp.
160func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
161 m.lastUserMessageTime = msg.CreatedAt
162 return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
163}
164
165// handleToolMessage updates existing tool calls with their results.
166func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
167 items := m.listCmp.Items()
168 for _, tr := range msg.ToolResults() {
169 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
170 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
171 toolCall.SetToolResult(tr)
172 m.listCmp.UpdateItem(toolCallIndex, toolCall)
173 }
174 }
175 return nil
176}
177
178// findToolCallByID searches for a tool call with the specified ID.
179// Returns the index if found, NotFound otherwise.
180func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
181 // Search backwards as tool calls are more likely to be recent
182 for i := len(items) - 1; i >= 0; i-- {
183 if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
184 return i
185 }
186 }
187 return NotFound
188}
189
190// handleUpdateAssistantMessage processes updates to assistant messages,
191// managing both message content and associated tool calls.
192func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
193 var cmds []tea.Cmd
194 items := m.listCmp.Items()
195
196 // Find existing assistant message and tool calls for this message
197 assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
198
199 logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
200
201 // Handle assistant message content
202 if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
203 cmds = append(cmds, cmd)
204 }
205
206 // Handle tool calls
207 if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
208 cmds = append(cmds, cmd)
209 }
210
211 return tea.Batch(cmds...)
212}
213
214// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
215func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
216 assistantIndex := NotFound
217 toolCalls := make(map[int]messages.ToolCallCmp)
218
219 // Search backwards as messages are more likely to be at the end
220 for i := len(items) - 1; i >= 0; i-- {
221 item := items[i]
222 if asMsg, ok := item.(messages.MessageCmp); ok {
223 if asMsg.GetMessage().ID == messageID {
224 assistantIndex = i
225 }
226 } else if tc, ok := item.(messages.ToolCallCmp); ok {
227 if tc.ParentMessageId() == messageID {
228 toolCalls[i] = tc
229 }
230 }
231 }
232
233 return assistantIndex, toolCalls
234}
235
236// updateAssistantMessageContent updates or removes the assistant message based on content.
237func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
238 if assistantIndex == NotFound {
239 return nil
240 }
241
242 shouldShowMessage := m.shouldShowAssistantMessage(msg)
243 hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
244
245 if shouldShowMessage {
246 m.listCmp.UpdateItem(
247 assistantIndex,
248 messages.NewMessageCmp(
249 msg,
250 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
251 ),
252 )
253 } else if hasToolCallsOnly {
254 m.listCmp.DeleteItem(assistantIndex)
255 }
256
257 return nil
258}
259
260// shouldShowAssistantMessage determines if an assistant message should be displayed.
261func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
262 return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
263}
264
265// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
266func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
267 var cmds []tea.Cmd
268
269 for _, tc := range msg.ToolCalls() {
270 if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
271 cmds = append(cmds, cmd)
272 }
273 }
274
275 return tea.Batch(cmds...)
276}
277
278// updateOrAddToolCall updates an existing tool call or adds a new one.
279func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
280 // Try to find existing tool call
281 for index, existingTC := range existingToolCalls {
282 if tc.ID == existingTC.GetToolCall().ID {
283 existingTC.SetToolCall(tc)
284 m.listCmp.UpdateItem(index, existingTC)
285 return nil
286 }
287 }
288
289 // Add new tool call if not found
290 return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
291}
292
293// handleNewAssistantMessage processes new assistant messages and their tool calls.
294func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
295 var cmds []tea.Cmd
296
297 // Add assistant message if it should be displayed
298 if m.shouldShowAssistantMessage(msg) {
299 cmd := m.listCmp.AppendItem(
300 messages.NewMessageCmp(
301 msg,
302 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
303 ),
304 )
305 cmds = append(cmds, cmd)
306 }
307
308 // Add tool calls
309 for _, tc := range msg.ToolCalls() {
310 cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
311 cmds = append(cmds, cmd)
312 }
313
314 return tea.Batch(cmds...)
315}
316
317// SetSession loads and displays messages for a new session.
318func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
319 if m.session.ID == session.ID {
320 return nil
321 }
322
323 m.session = session
324 sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
325 if err != nil {
326 return util.ReportError(err)
327 }
328
329 if len(sessionMessages) == 0 {
330 return m.listCmp.SetItems([]util.Model{})
331 }
332
333 // Initialize with first message timestamp
334 m.lastUserMessageTime = sessionMessages[0].CreatedAt
335
336 // Build tool result map for efficient lookup
337 toolResultMap := m.buildToolResultMap(sessionMessages)
338
339 // Convert messages to UI components
340 uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
341
342 return m.listCmp.SetItems(uiMessages)
343}
344
345// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
346func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
347 toolResultMap := make(map[string]message.ToolResult)
348 for _, msg := range messages {
349 for _, tr := range msg.ToolResults() {
350 toolResultMap[tr.ToolCallID] = tr
351 }
352 }
353 return toolResultMap
354}
355
356// convertMessagesToUI converts database messages to UI components.
357func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
358 uiMessages := make([]util.Model, 0)
359
360 for _, msg := range sessionMessages {
361 switch msg.Role {
362 case message.User:
363 m.lastUserMessageTime = msg.CreatedAt
364 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
365 case message.Assistant:
366 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
367 }
368 }
369
370 return uiMessages
371}
372
373// convertAssistantMessage converts an assistant message and its tool calls to UI components.
374func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
375 var uiMessages []util.Model
376
377 // Add assistant message if it should be displayed
378 if m.shouldShowAssistantMessage(msg) {
379 uiMessages = append(
380 uiMessages,
381 messages.NewMessageCmp(
382 msg,
383 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
384 ),
385 )
386 }
387
388 // Add tool calls with their results and status
389 for _, tc := range msg.ToolCalls() {
390 options := m.buildToolCallOptions(tc, msg, toolResultMap)
391 uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
392 }
393
394 return uiMessages
395}
396
397// buildToolCallOptions creates options for tool call components based on results and status.
398func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
399 var options []messages.ToolCallOption
400
401 // Add tool result if available
402 if tr, ok := toolResultMap[tc.ID]; ok {
403 options = append(options, messages.WithToolCallResult(tr))
404 }
405
406 // Add cancelled status if applicable
407 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
408 options = append(options, messages.WithToolCallCancelled())
409 }
410
411 return options
412}
413
414// GetSize returns the current width and height of the component.
415func (m *messageListCmp) GetSize() (int, int) {
416 return m.width, m.height
417}
418
419// SetSize updates the component dimensions and propagates to the list component.
420func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
421 m.width = width
422 m.height = height - 1
423 return m.listCmp.SetSize(width, height-1)
424}