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