list.go

  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}