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.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}