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