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/crush/internal/app"
 10	"github.com/charmbracelet/crush/internal/llm/agent"
 11	"github.com/charmbracelet/crush/internal/message"
 12	"github.com/charmbracelet/crush/internal/permission"
 13	"github.com/charmbracelet/crush/internal/pubsub"
 14	"github.com/charmbracelet/crush/internal/session"
 15	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
 16	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 17	"github.com/charmbracelet/crush/internal/tui/exp/list"
 18	"github.com/charmbracelet/crush/internal/tui/styles"
 19	"github.com/charmbracelet/crush/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	layout.Focusable
 41	layout.Help
 42
 43	SetSession(session.Session) tea.Cmd
 44	GoToBottom() tea.Cmd
 45}
 46
 47// messageListCmp implements MessageListCmp, providing a virtualized list
 48// of chat messages with support for tool calls, real-time updates, and
 49// session switching.
 50type messageListCmp struct {
 51	app              *app.App
 52	width, height    int
 53	session          session.Session
 54	listCmp          list.List[list.Item]
 55	previousSelected string // Last selected item index for restoring focus
 56
 57	lastUserMessageTime int64
 58	defaultListKeyMap   list.KeyMap
 59}
 60
 61// New creates a new message list component with custom keybindings
 62// and reverse ordering (newest messages at bottom).
 63func New(app *app.App) MessageListCmp {
 64	defaultListKeyMap := list.DefaultKeyMap()
 65	listCmp := list.New(
 66		[]list.Item{},
 67		list.WithGap(1),
 68		list.WithDirectionBackward(),
 69		list.WithFocus(false),
 70		list.WithKeyMap(defaultListKeyMap),
 71		list.WithEnableMouse(),
 72	)
 73	return &messageListCmp{
 74		app:               app,
 75		listCmp:           listCmp,
 76		previousSelected:  "",
 77		defaultListKeyMap: defaultListKeyMap,
 78	}
 79}
 80
 81// Init initializes the component.
 82func (m *messageListCmp) Init() tea.Cmd {
 83	return m.listCmp.Init()
 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 pubsub.Event[permission.PermissionNotification]:
 90		return m, m.handlePermissionRequest(msg.Payload)
 91	case SessionSelectedMsg:
 92		if msg.ID != m.session.ID {
 93			cmd := m.SetSession(msg)
 94			return m, cmd
 95		}
 96		return m, nil
 97	case SessionClearedMsg:
 98		m.session = session.Session{}
 99		return m, m.listCmp.SetItems([]list.Item{})
100
101	case pubsub.Event[message.Message]:
102		cmd := m.handleMessageEvent(msg)
103		return m, cmd
104
105	case tea.MouseWheelMsg:
106		u, cmd := m.listCmp.Update(msg)
107		m.listCmp = u.(list.List[list.Item])
108		return m, cmd
109	default:
110		var cmds []tea.Cmd
111		u, cmd := m.listCmp.Update(msg)
112		m.listCmp = u.(list.List[list.Item])
113		cmds = append(cmds, cmd)
114		return m, tea.Batch(cmds...)
115	}
116}
117
118// View renders the message list or an initial screen if empty.
119func (m *messageListCmp) View() string {
120	t := styles.CurrentTheme()
121	return t.S().Base.
122		Padding(1, 1, 0, 1).
123		Width(m.width).
124		Height(m.height).
125		Render(
126			m.listCmp.View(),
127		)
128}
129
130func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
131	items := m.listCmp.Items()
132	if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
133		toolCall := items[toolCallIndex].(messages.ToolCallCmp)
134		toolCall.SetPermissionRequested()
135		if permission.Granted {
136			toolCall.SetPermissionGranted()
137		}
138		m.listCmp.UpdateItem(toolCall.ID(), toolCall)
139	}
140	return nil
141}
142
143// handleChildSession handles messages from child sessions (agent tools).
144func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
145	var cmds []tea.Cmd
146	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
147		return nil
148	}
149	items := m.listCmp.Items()
150	toolCallInx := NotFound
151	var toolCall messages.ToolCallCmp
152	for i := len(items) - 1; i >= 0; i-- {
153		if msg, ok := items[i].(messages.ToolCallCmp); ok {
154			if msg.GetToolCall().ID == event.Payload.SessionID {
155				toolCallInx = i
156				toolCall = msg
157			}
158		}
159	}
160	if toolCallInx == NotFound {
161		return nil
162	}
163	nestedToolCalls := toolCall.GetNestedToolCalls()
164	for _, tc := range event.Payload.ToolCalls() {
165		found := false
166		for existingInx, existingTC := range nestedToolCalls {
167			if existingTC.GetToolCall().ID == tc.ID {
168				nestedToolCalls[existingInx].SetToolCall(tc)
169				found = true
170				break
171			}
172		}
173		if !found {
174			nestedCall := messages.NewToolCallCmp(
175				event.Payload.ID,
176				tc,
177				m.app.Permissions,
178				messages.WithToolCallNested(true),
179			)
180			cmds = append(cmds, nestedCall.Init())
181			nestedToolCalls = append(
182				nestedToolCalls,
183				nestedCall,
184			)
185		}
186	}
187	for _, tr := range event.Payload.ToolResults() {
188		for nestedInx, nestedTC := range nestedToolCalls {
189			if nestedTC.GetToolCall().ID == tr.ToolCallID {
190				nestedToolCalls[nestedInx].SetToolResult(tr)
191				break
192			}
193		}
194	}
195
196	toolCall.SetNestedToolCalls(nestedToolCalls)
197	m.listCmp.UpdateItem(
198		toolCall.ID(),
199		toolCall,
200	)
201	return tea.Batch(cmds...)
202}
203
204// handleMessageEvent processes different types of message events (created/updated).
205func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
206	switch event.Type {
207	case pubsub.CreatedEvent:
208		if event.Payload.SessionID != m.session.ID {
209			return m.handleChildSession(event)
210		}
211		if m.messageExists(event.Payload.ID) {
212			return nil
213		}
214		return m.handleNewMessage(event.Payload)
215	case pubsub.UpdatedEvent:
216		if event.Payload.SessionID != m.session.ID {
217			return m.handleChildSession(event)
218		}
219		switch event.Payload.Role {
220		case message.Assistant:
221			return m.handleUpdateAssistantMessage(event.Payload)
222		case message.Tool:
223			return m.handleToolMessage(event.Payload)
224		}
225	}
226	return nil
227}
228
229// messageExists checks if a message with the given ID already exists in the list.
230func (m *messageListCmp) messageExists(messageID string) bool {
231	items := m.listCmp.Items()
232	// Search backwards as new messages are more likely to be at the end
233	for i := len(items) - 1; i >= 0; i-- {
234		if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
235			return true
236		}
237	}
238	return false
239}
240
241// handleNewMessage routes new messages to appropriate handlers based on role.
242func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
243	switch msg.Role {
244	case message.User:
245		return m.handleNewUserMessage(msg)
246	case message.Assistant:
247		return m.handleNewAssistantMessage(msg)
248	case message.Tool:
249		return m.handleToolMessage(msg)
250	}
251	return nil
252}
253
254// handleNewUserMessage adds a new user message to the list and updates the timestamp.
255func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
256	m.lastUserMessageTime = msg.CreatedAt
257	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
258}
259
260// handleToolMessage updates existing tool calls with their results.
261func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
262	items := m.listCmp.Items()
263	for _, tr := range msg.ToolResults() {
264		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
265			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
266			toolCall.SetToolResult(tr)
267			m.listCmp.UpdateItem(toolCall.ID(), toolCall)
268		}
269	}
270	return nil
271}
272
273// findToolCallByID searches for a tool call with the specified ID.
274// Returns the index if found, NotFound otherwise.
275func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
276	// Search backwards as tool calls are more likely to be recent
277	for i := len(items) - 1; i >= 0; i-- {
278		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
279			return i
280		}
281	}
282	return NotFound
283}
284
285// handleUpdateAssistantMessage processes updates to assistant messages,
286// managing both message content and associated tool calls.
287func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
288	var cmds []tea.Cmd
289	items := m.listCmp.Items()
290
291	// Find existing assistant message and tool calls for this message
292	assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
293
294	// Handle assistant message content
295	if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
296		cmds = append(cmds, cmd)
297	}
298
299	// Handle tool calls
300	if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
301		cmds = append(cmds, cmd)
302	}
303
304	return tea.Batch(cmds...)
305}
306
307// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
308func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
309	assistantIndex := NotFound
310	toolCalls := make(map[int]messages.ToolCallCmp)
311
312	// Search backwards as messages are more likely to be at the end
313	for i := len(items) - 1; i >= 0; i-- {
314		item := items[i]
315		if asMsg, ok := item.(messages.MessageCmp); ok {
316			if asMsg.GetMessage().ID == messageID {
317				assistantIndex = i
318			}
319		} else if tc, ok := item.(messages.ToolCallCmp); ok {
320			if tc.ParentMessageID() == messageID {
321				toolCalls[i] = tc
322			}
323		}
324	}
325
326	return assistantIndex, toolCalls
327}
328
329// updateAssistantMessageContent updates or removes the assistant message based on content.
330func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
331	if assistantIndex == NotFound {
332		return nil
333	}
334
335	shouldShowMessage := m.shouldShowAssistantMessage(msg)
336	hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
337
338	var cmd tea.Cmd
339	if shouldShowMessage {
340		items := m.listCmp.Items()
341		uiMsg := items[assistantIndex].(messages.MessageCmp)
342		uiMsg.SetMessage(msg)
343		m.listCmp.UpdateItem(
344			items[assistantIndex].ID(),
345			uiMsg,
346		)
347		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
348			m.listCmp.AppendItem(
349				messages.NewAssistantSection(
350					msg,
351					time.Unix(m.lastUserMessageTime, 0),
352				),
353			)
354		}
355	} else if hasToolCallsOnly {
356		items := m.listCmp.Items()
357		m.listCmp.DeleteItem(items[assistantIndex].ID())
358	}
359
360	return cmd
361}
362
363// shouldShowAssistantMessage determines if an assistant message should be displayed.
364func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
365	return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
366}
367
368// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
369func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
370	var cmds []tea.Cmd
371
372	for _, tc := range msg.ToolCalls() {
373		if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
374			cmds = append(cmds, cmd)
375		}
376	}
377
378	return tea.Batch(cmds...)
379}
380
381// updateOrAddToolCall updates an existing tool call or adds a new one.
382func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
383	// Try to find existing tool call
384	for _, existingTC := range existingToolCalls {
385		if tc.ID == existingTC.GetToolCall().ID {
386			existingTC.SetToolCall(tc)
387			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
388				existingTC.SetCancelled()
389			}
390			m.listCmp.UpdateItem(tc.ID, existingTC)
391			return nil
392		}
393	}
394
395	// Add new tool call if not found
396	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
397}
398
399// handleNewAssistantMessage processes new assistant messages and their tool calls.
400func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
401	var cmds []tea.Cmd
402
403	// Add assistant message if it should be displayed
404	if m.shouldShowAssistantMessage(msg) {
405		cmd := m.listCmp.AppendItem(
406			messages.NewMessageCmp(
407				msg,
408			),
409		)
410		cmds = append(cmds, cmd)
411	}
412
413	// Add tool calls
414	for _, tc := range msg.ToolCalls() {
415		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
416		cmds = append(cmds, cmd)
417	}
418
419	return tea.Batch(cmds...)
420}
421
422// SetSession loads and displays messages for a new session.
423func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
424	if m.session.ID == session.ID {
425		return nil
426	}
427
428	m.session = session
429	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
430	if err != nil {
431		return util.ReportError(err)
432	}
433
434	if len(sessionMessages) == 0 {
435		return m.listCmp.SetItems([]list.Item{})
436	}
437
438	// Initialize with first message timestamp
439	m.lastUserMessageTime = sessionMessages[0].CreatedAt
440
441	// Build tool result map for efficient lookup
442	toolResultMap := m.buildToolResultMap(sessionMessages)
443
444	// Convert messages to UI components
445	uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
446
447	return m.listCmp.SetItems(uiMessages)
448}
449
450// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
451func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
452	toolResultMap := make(map[string]message.ToolResult)
453	for _, msg := range messages {
454		for _, tr := range msg.ToolResults() {
455			toolResultMap[tr.ToolCallID] = tr
456		}
457	}
458	return toolResultMap
459}
460
461// convertMessagesToUI converts database messages to UI components.
462func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
463	uiMessages := make([]list.Item, 0)
464
465	for _, msg := range sessionMessages {
466		switch msg.Role {
467		case message.User:
468			m.lastUserMessageTime = msg.CreatedAt
469			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
470		case message.Assistant:
471			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
472			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
473				uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
474			}
475		}
476	}
477
478	return uiMessages
479}
480
481// convertAssistantMessage converts an assistant message and its tool calls to UI components.
482func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
483	var uiMessages []list.Item
484
485	// Add assistant message if it should be displayed
486	if m.shouldShowAssistantMessage(msg) {
487		uiMessages = append(
488			uiMessages,
489			messages.NewMessageCmp(
490				msg,
491			),
492		)
493	}
494
495	// Add tool calls with their results and status
496	for _, tc := range msg.ToolCalls() {
497		options := m.buildToolCallOptions(tc, msg, toolResultMap)
498		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
499		// If this tool call is the agent tool, fetch nested tool calls
500		if tc.Name == agent.AgentToolName {
501			nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
502			nestedToolResultMap := m.buildToolResultMap(nestedMessages)
503			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
504			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
505			for _, nestedMsg := range nestedUIMessages {
506				if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
507					toolCall.SetIsNested(true)
508					nestedToolCalls = append(nestedToolCalls, toolCall)
509				}
510			}
511			uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
512		}
513	}
514
515	return uiMessages
516}
517
518// buildToolCallOptions creates options for tool call components based on results and status.
519func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
520	var options []messages.ToolCallOption
521
522	// Add tool result if available
523	if tr, ok := toolResultMap[tc.ID]; ok {
524		options = append(options, messages.WithToolCallResult(tr))
525	}
526
527	// Add cancelled status if applicable
528	if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
529		options = append(options, messages.WithToolCallCancelled())
530	}
531
532	return options
533}
534
535// GetSize returns the current width and height of the component.
536func (m *messageListCmp) GetSize() (int, int) {
537	return m.width, m.height
538}
539
540// SetSize updates the component dimensions and propagates to the list component.
541func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
542	m.width = width
543	m.height = height
544	return m.listCmp.SetSize(width-2, height-1) // for padding
545}
546
547// Blur implements MessageListCmp.
548func (m *messageListCmp) Blur() tea.Cmd {
549	return m.listCmp.Blur()
550}
551
552// Focus implements MessageListCmp.
553func (m *messageListCmp) Focus() tea.Cmd {
554	return m.listCmp.Focus()
555}
556
557// IsFocused implements MessageListCmp.
558func (m *messageListCmp) IsFocused() bool {
559	return m.listCmp.IsFocused()
560}
561
562func (m *messageListCmp) Bindings() []key.Binding {
563	return m.defaultListKeyMap.KeyBindings()
564}
565
566func (m *messageListCmp) GoToBottom() tea.Cmd {
567	return m.listCmp.GoToBottom()
568}