chat.go

  1package chat
  2
  3import (
  4	"context"
  5	"strings"
  6	"time"
  7
  8	"github.com/atotto/clipboard"
  9	"github.com/charmbracelet/bubbles/v2/key"
 10	tea "github.com/charmbracelet/bubbletea/v2"
 11	"github.com/charmbracelet/crush/internal/agent"
 12	"github.com/charmbracelet/crush/internal/app"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/crush/internal/permission"
 15	"github.com/charmbracelet/crush/internal/pubsub"
 16	"github.com/charmbracelet/crush/internal/session"
 17	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
 18	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 19	"github.com/charmbracelet/crush/internal/tui/exp/list"
 20	"github.com/charmbracelet/crush/internal/tui/styles"
 21	"github.com/charmbracelet/crush/internal/tui/util"
 22)
 23
 24type SendMsg struct {
 25	Text        string
 26	Attachments []message.Attachment
 27}
 28
 29type SessionSelectedMsg = session.Session
 30
 31type SessionClearedMsg struct{}
 32
 33type SelectionCopyMsg struct {
 34	clickCount   int
 35	endSelection bool
 36	x, y         int
 37}
 38
 39const (
 40	NotFound = -1
 41)
 42
 43// MessageListCmp represents a component that displays a list of chat messages
 44// with support for real-time updates and session management.
 45type MessageListCmp interface {
 46	util.Model
 47	layout.Sizeable
 48	layout.Focusable
 49	layout.Help
 50
 51	SetSession(session.Session) tea.Cmd
 52	GoToBottom() tea.Cmd
 53	GetSelectedText() string
 54	CopySelectedText(bool) tea.Cmd
 55}
 56
 57// messageListCmp implements MessageListCmp, providing a virtualized list
 58// of chat messages with support for tool calls, real-time updates, and
 59// session switching.
 60type messageListCmp struct {
 61	app              *app.App
 62	width, height    int
 63	session          session.Session
 64	listCmp          list.List[list.Item]
 65	previousSelected string // Last selected item index for restoring focus
 66
 67	lastUserMessageTime int64
 68	defaultListKeyMap   list.KeyMap
 69
 70	// Click tracking for double/triple click detection
 71	lastClickTime time.Time
 72	lastClickX    int
 73	lastClickY    int
 74	clickCount    int
 75	promptQueue   int
 76}
 77
 78// New creates a new message list component with custom keybindings
 79// and reverse ordering (newest messages at bottom).
 80func New(app *app.App) MessageListCmp {
 81	defaultListKeyMap := list.DefaultKeyMap()
 82	listCmp := list.New(
 83		[]list.Item{},
 84		list.WithGap(1),
 85		list.WithDirectionBackward(),
 86		list.WithFocus(false),
 87		list.WithKeyMap(defaultListKeyMap),
 88		list.WithEnableMouse(),
 89	)
 90	return &messageListCmp{
 91		app:               app,
 92		listCmp:           listCmp,
 93		previousSelected:  "",
 94		defaultListKeyMap: defaultListKeyMap,
 95	}
 96}
 97
 98// Init initializes the component.
 99func (m *messageListCmp) Init() tea.Cmd {
100	return m.listCmp.Init()
101}
102
103// Update handles incoming messages and updates the component state.
104func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105	var cmds []tea.Cmd
106	if m.session.ID != "" && m.app.AgentCoordinator != nil {
107		queueSize := m.app.AgentCoordinator.QueuedPrompts(m.session.ID)
108		if queueSize != m.promptQueue {
109			m.promptQueue = queueSize
110			cmds = append(cmds, m.SetSize(m.width, m.height))
111		}
112	}
113	switch msg := msg.(type) {
114	case tea.KeyPressMsg:
115		if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
116			switch {
117			case key.Matches(msg, messages.CopyKey):
118				cmds = append(cmds, m.CopySelectedText(true))
119				return m, tea.Batch(cmds...)
120			case key.Matches(msg, messages.ClearSelectionKey):
121				cmds = append(cmds, m.SelectionClear())
122				return m, tea.Batch(cmds...)
123			}
124		}
125	case tea.MouseClickMsg:
126		x := msg.X - 1 // Adjust for padding
127		y := msg.Y - 1 // Adjust for padding
128		if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
129			return m, nil // Ignore clicks outside the component
130		}
131		if msg.Button == tea.MouseLeft {
132			cmds = append(cmds, m.handleMouseClick(x, y))
133			return m, tea.Batch(cmds...)
134		}
135		return m, tea.Batch(cmds...)
136	case tea.MouseMotionMsg:
137		x := msg.X - 1 // Adjust for padding
138		y := msg.Y - 1 // Adjust for padding
139		if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
140			if y < 0 {
141				cmds = append(cmds, m.listCmp.MoveUp(1))
142				return m, tea.Batch(cmds...)
143			}
144			if y >= m.height-1 {
145				cmds = append(cmds, m.listCmp.MoveDown(1))
146				return m, tea.Batch(cmds...)
147			}
148			return m, nil // Ignore clicks outside the component
149		}
150		if msg.Button == tea.MouseLeft {
151			m.listCmp.EndSelection(x, y)
152		}
153		return m, tea.Batch(cmds...)
154	case tea.MouseReleaseMsg:
155		x := msg.X - 1 // Adjust for padding
156		y := msg.Y - 1 // Adjust for padding
157		if msg.Button == tea.MouseLeft {
158			clickCount := m.clickCount
159			if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
160				tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
161					return SelectionCopyMsg{
162						clickCount:   clickCount,
163						endSelection: false,
164					}
165				})
166
167				cmds = append(cmds, tick)
168				return m, tea.Batch(cmds...)
169			}
170			tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
171				return SelectionCopyMsg{
172					clickCount:   clickCount,
173					endSelection: true,
174					x:            x,
175					y:            y,
176				}
177			})
178			cmds = append(cmds, tick)
179			return m, tea.Batch(cmds...)
180		}
181		return m, nil
182	case SelectionCopyMsg:
183		if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
184			// If the click count matches and within threshold, copy selected text
185			if msg.endSelection {
186				m.listCmp.EndSelection(msg.x, msg.y)
187			}
188			m.listCmp.SelectionStop()
189			cmds = append(cmds, m.CopySelectedText(true))
190			return m, tea.Batch(cmds...)
191		}
192	case pubsub.Event[permission.PermissionNotification]:
193		cmds = append(cmds, m.handlePermissionRequest(msg.Payload))
194		return m, tea.Batch(cmds...)
195	case SessionSelectedMsg:
196		if msg.ID != m.session.ID {
197			cmds = append(cmds, m.SetSession(msg))
198		}
199		return m, tea.Batch(cmds...)
200	case SessionClearedMsg:
201		m.session = session.Session{}
202		cmds = append(cmds, m.listCmp.SetItems([]list.Item{}))
203		return m, tea.Batch(cmds...)
204
205	case pubsub.Event[message.Message]:
206		cmds = append(cmds, m.handleMessageEvent(msg))
207		return m, tea.Batch(cmds...)
208
209	case tea.MouseWheelMsg:
210		u, cmd := m.listCmp.Update(msg)
211		m.listCmp = u.(list.List[list.Item])
212		cmds = append(cmds, cmd)
213		return m, tea.Batch(cmds...)
214	}
215
216	u, cmd := m.listCmp.Update(msg)
217	m.listCmp = u.(list.List[list.Item])
218	cmds = append(cmds, cmd)
219	return m, tea.Batch(cmds...)
220}
221
222// View renders the message list or an initial screen if empty.
223func (m *messageListCmp) View() string {
224	t := styles.CurrentTheme()
225	height := m.height
226	if m.promptQueue > 0 {
227		height -= 4 // pill height and padding
228	}
229	view := []string{
230		t.S().Base.
231			Padding(1, 1, 0, 1).
232			Width(m.width).
233			Height(height).
234			Render(
235				m.listCmp.View(),
236			),
237	}
238	if m.app.AgentCoordinator != nil && m.promptQueue > 0 {
239		queuePill := queuePill(m.promptQueue, t)
240		view = append(view, t.S().Base.PaddingLeft(4).PaddingTop(1).Render(queuePill))
241	}
242	return strings.Join(view, "\n")
243}
244
245func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
246	items := m.listCmp.Items()
247	if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
248		toolCall := items[toolCallIndex].(messages.ToolCallCmp)
249		toolCall.SetPermissionRequested()
250		if permission.Granted {
251			toolCall.SetPermissionGranted()
252		}
253		m.listCmp.UpdateItem(toolCall.ID(), toolCall)
254	}
255	return nil
256}
257
258// handleChildSession handles messages from child sessions (agent tools).
259func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
260	var cmds []tea.Cmd
261	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
262		return nil
263	}
264
265	// Check if this is an agent tool session and parse it
266	childSessionID := event.Payload.SessionID
267	parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
268	if !ok {
269		return nil
270	}
271	items := m.listCmp.Items()
272	toolCallInx := NotFound
273	var toolCall messages.ToolCallCmp
274	for i := len(items) - 1; i >= 0; i-- {
275		if msg, ok := items[i].(messages.ToolCallCmp); ok {
276			if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
277				toolCallInx = i
278				toolCall = msg
279			}
280		}
281	}
282	if toolCallInx == NotFound {
283		return nil
284	}
285	nestedToolCalls := toolCall.GetNestedToolCalls()
286	for _, tc := range event.Payload.ToolCalls() {
287		found := false
288		for existingInx, existingTC := range nestedToolCalls {
289			if existingTC.GetToolCall().ID == tc.ID {
290				nestedToolCalls[existingInx].SetToolCall(tc)
291				found = true
292				break
293			}
294		}
295		if !found {
296			nestedCall := messages.NewToolCallCmp(
297				event.Payload.ID,
298				tc,
299				m.app.Permissions,
300				messages.WithToolCallNested(true),
301			)
302			cmds = append(cmds, nestedCall.Init())
303			nestedToolCalls = append(
304				nestedToolCalls,
305				nestedCall,
306			)
307		}
308	}
309	for _, tr := range event.Payload.ToolResults() {
310		for nestedInx, nestedTC := range nestedToolCalls {
311			if nestedTC.GetToolCall().ID == tr.ToolCallID {
312				nestedToolCalls[nestedInx].SetToolResult(tr)
313				break
314			}
315		}
316	}
317
318	toolCall.SetNestedToolCalls(nestedToolCalls)
319	m.listCmp.UpdateItem(
320		toolCall.ID(),
321		toolCall,
322	)
323	return tea.Batch(cmds...)
324}
325
326// handleMessageEvent processes different types of message events (created/updated).
327func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
328	switch event.Type {
329	case pubsub.CreatedEvent:
330		if event.Payload.SessionID != m.session.ID {
331			return m.handleChildSession(event)
332		}
333		if m.messageExists(event.Payload.ID) {
334			return nil
335		}
336		return m.handleNewMessage(event.Payload)
337	case pubsub.DeletedEvent:
338		if event.Payload.SessionID != m.session.ID {
339			return nil
340		}
341		return m.handleDeleteMessage(event.Payload)
342	case pubsub.UpdatedEvent:
343		if event.Payload.SessionID != m.session.ID {
344			return m.handleChildSession(event)
345		}
346		switch event.Payload.Role {
347		case message.Assistant:
348			return m.handleUpdateAssistantMessage(event.Payload)
349		case message.Tool:
350			return m.handleToolMessage(event.Payload)
351		}
352	}
353	return nil
354}
355
356// messageExists checks if a message with the given ID already exists in the list.
357func (m *messageListCmp) messageExists(messageID string) bool {
358	items := m.listCmp.Items()
359	// Search backwards as new messages are more likely to be at the end
360	for i := len(items) - 1; i >= 0; i-- {
361		if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
362			return true
363		}
364	}
365	return false
366}
367
368// handleDeleteMessage removes a message from the list.
369func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
370	items := m.listCmp.Items()
371	for i := len(items) - 1; i >= 0; i-- {
372		if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
373			m.listCmp.DeleteItem(items[i].ID())
374			return nil
375		}
376	}
377	return nil
378}
379
380// handleNewMessage routes new messages to appropriate handlers based on role.
381func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
382	switch msg.Role {
383	case message.User:
384		return m.handleNewUserMessage(msg)
385	case message.Assistant:
386		return m.handleNewAssistantMessage(msg)
387	case message.Tool:
388		return m.handleToolMessage(msg)
389	}
390	return nil
391}
392
393// handleNewUserMessage adds a new user message to the list and updates the timestamp.
394func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
395	m.lastUserMessageTime = msg.CreatedAt
396	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
397}
398
399// handleToolMessage updates existing tool calls with their results.
400func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
401	items := m.listCmp.Items()
402	for _, tr := range msg.ToolResults() {
403		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
404			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
405			toolCall.SetToolResult(tr)
406			m.listCmp.UpdateItem(toolCall.ID(), toolCall)
407		}
408	}
409	return nil
410}
411
412// findToolCallByID searches for a tool call with the specified ID.
413// Returns the index if found, NotFound otherwise.
414func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
415	// Search backwards as tool calls are more likely to be recent
416	for i := len(items) - 1; i >= 0; i-- {
417		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
418			return i
419		}
420	}
421	return NotFound
422}
423
424// handleUpdateAssistantMessage processes updates to assistant messages,
425// managing both message content and associated tool calls.
426func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
427	var cmds []tea.Cmd
428	items := m.listCmp.Items()
429
430	// Find existing assistant message and tool calls for this message
431	assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
432
433	// Handle assistant message content
434	if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
435		cmds = append(cmds, cmd)
436	}
437
438	// Handle tool calls
439	if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
440		cmds = append(cmds, cmd)
441	}
442
443	return tea.Batch(cmds...)
444}
445
446// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
447func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
448	assistantIndex := NotFound
449	toolCalls := make(map[int]messages.ToolCallCmp)
450
451	// Search backwards as messages are more likely to be at the end
452	for i := len(items) - 1; i >= 0; i-- {
453		item := items[i]
454		if asMsg, ok := item.(messages.MessageCmp); ok {
455			if asMsg.GetMessage().ID == messageID {
456				assistantIndex = i
457			}
458		} else if tc, ok := item.(messages.ToolCallCmp); ok {
459			if tc.ParentMessageID() == messageID {
460				toolCalls[i] = tc
461			}
462		}
463	}
464
465	return assistantIndex, toolCalls
466}
467
468// updateAssistantMessageContent updates or removes the assistant message based on content.
469func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
470	if assistantIndex == NotFound {
471		return nil
472	}
473
474	shouldShowMessage := m.shouldShowAssistantMessage(msg)
475	hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
476
477	var cmd tea.Cmd
478	if shouldShowMessage {
479		items := m.listCmp.Items()
480		uiMsg := items[assistantIndex].(messages.MessageCmp)
481		uiMsg.SetMessage(msg)
482		m.listCmp.UpdateItem(
483			items[assistantIndex].ID(),
484			uiMsg,
485		)
486		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
487			m.listCmp.AppendItem(
488				messages.NewAssistantSection(
489					msg,
490					time.Unix(m.lastUserMessageTime, 0),
491				),
492			)
493		}
494	} else if hasToolCallsOnly {
495		items := m.listCmp.Items()
496		m.listCmp.DeleteItem(items[assistantIndex].ID())
497	}
498
499	return cmd
500}
501
502// shouldShowAssistantMessage determines if an assistant message should be displayed.
503func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
504	return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
505}
506
507// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
508func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
509	var cmds []tea.Cmd
510
511	for _, tc := range msg.ToolCalls() {
512		if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
513			cmds = append(cmds, cmd)
514		}
515	}
516
517	return tea.Batch(cmds...)
518}
519
520// updateOrAddToolCall updates an existing tool call or adds a new one.
521func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
522	// Try to find existing tool call
523	for _, existingTC := range existingToolCalls {
524		if tc.ID == existingTC.GetToolCall().ID {
525			existingTC.SetToolCall(tc)
526			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
527				existingTC.SetCancelled()
528			}
529			m.listCmp.UpdateItem(tc.ID, existingTC)
530			return nil
531		}
532	}
533
534	// Add new tool call if not found
535	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
536}
537
538// handleNewAssistantMessage processes new assistant messages and their tool calls.
539func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
540	var cmds []tea.Cmd
541
542	// Add assistant message if it should be displayed
543	if m.shouldShowAssistantMessage(msg) {
544		cmd := m.listCmp.AppendItem(
545			messages.NewMessageCmp(
546				msg,
547			),
548		)
549		cmds = append(cmds, cmd)
550	}
551
552	// Add tool calls
553	for _, tc := range msg.ToolCalls() {
554		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
555		cmds = append(cmds, cmd)
556	}
557
558	return tea.Batch(cmds...)
559}
560
561// SetSession loads and displays messages for a new session.
562func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
563	if m.session.ID == session.ID {
564		return nil
565	}
566
567	m.session = session
568	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
569	if err != nil {
570		return util.ReportError(err)
571	}
572
573	if len(sessionMessages) == 0 {
574		return m.listCmp.SetItems([]list.Item{})
575	}
576
577	// Initialize with first message timestamp
578	m.lastUserMessageTime = sessionMessages[0].CreatedAt
579
580	// Build tool result map for efficient lookup
581	toolResultMap := m.buildToolResultMap(sessionMessages)
582
583	// Convert messages to UI components
584	uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
585
586	return m.listCmp.SetItems(uiMessages)
587}
588
589// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
590func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
591	toolResultMap := make(map[string]message.ToolResult)
592	for _, msg := range messages {
593		for _, tr := range msg.ToolResults() {
594			toolResultMap[tr.ToolCallID] = tr
595		}
596	}
597	return toolResultMap
598}
599
600// convertMessagesToUI converts database messages to UI components.
601func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
602	uiMessages := make([]list.Item, 0)
603
604	for _, msg := range sessionMessages {
605		switch msg.Role {
606		case message.User:
607			m.lastUserMessageTime = msg.CreatedAt
608			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
609		case message.Assistant:
610			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
611			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
612				uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
613			}
614		}
615	}
616
617	return uiMessages
618}
619
620// convertAssistantMessage converts an assistant message and its tool calls to UI components.
621func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
622	var uiMessages []list.Item
623
624	// Add assistant message if it should be displayed
625	if m.shouldShowAssistantMessage(msg) {
626		uiMessages = append(
627			uiMessages,
628			messages.NewMessageCmp(
629				msg,
630			),
631		)
632	}
633
634	// Add tool calls with their results and status
635	for _, tc := range msg.ToolCalls() {
636		options := m.buildToolCallOptions(tc, msg, toolResultMap)
637		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
638		// If this tool call is the agent tool, fetch nested tool calls
639		if tc.Name == agent.AgentToolName {
640			agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
641			nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
642			nestedToolResultMap := m.buildToolResultMap(nestedMessages)
643			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
644			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
645			for _, nestedMsg := range nestedUIMessages {
646				if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
647					toolCall.SetIsNested(true)
648					nestedToolCalls = append(nestedToolCalls, toolCall)
649				}
650			}
651			uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
652		}
653	}
654
655	return uiMessages
656}
657
658// buildToolCallOptions creates options for tool call components based on results and status.
659func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
660	var options []messages.ToolCallOption
661
662	// Add tool result if available
663	if tr, ok := toolResultMap[tc.ID]; ok {
664		options = append(options, messages.WithToolCallResult(tr))
665	}
666
667	// Add cancelled status if applicable
668	if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
669		options = append(options, messages.WithToolCallCancelled())
670	}
671
672	return options
673}
674
675// GetSize returns the current width and height of the component.
676func (m *messageListCmp) GetSize() (int, int) {
677	return m.width, m.height
678}
679
680// SetSize updates the component dimensions and propagates to the list component.
681func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
682	m.width = width
683	m.height = height
684	if m.promptQueue > 0 {
685		queueHeight := 3 + 1 // 1 for padding top
686		lHight := max(0, height-(1+queueHeight))
687		return m.listCmp.SetSize(width-2, lHight)
688	}
689	return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
690}
691
692// Blur implements MessageListCmp.
693func (m *messageListCmp) Blur() tea.Cmd {
694	return m.listCmp.Blur()
695}
696
697// Focus implements MessageListCmp.
698func (m *messageListCmp) Focus() tea.Cmd {
699	return m.listCmp.Focus()
700}
701
702// IsFocused implements MessageListCmp.
703func (m *messageListCmp) IsFocused() bool {
704	return m.listCmp.IsFocused()
705}
706
707func (m *messageListCmp) Bindings() []key.Binding {
708	return m.defaultListKeyMap.KeyBindings()
709}
710
711func (m *messageListCmp) GoToBottom() tea.Cmd {
712	return m.listCmp.GoToBottom()
713}
714
715const (
716	doubleClickThreshold = 500 * time.Millisecond
717	clickTolerance       = 2 // pixels
718)
719
720// handleMouseClick handles mouse click events and detects double/triple clicks.
721func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
722	now := time.Now()
723
724	// Check if this is a potential multi-click
725	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
726		abs(x-m.lastClickX) <= clickTolerance &&
727		abs(y-m.lastClickY) <= clickTolerance {
728		m.clickCount++
729	} else {
730		m.clickCount = 1
731	}
732
733	m.lastClickTime = now
734	m.lastClickX = x
735	m.lastClickY = y
736
737	switch m.clickCount {
738	case 1:
739		// Single click - start selection
740		m.listCmp.StartSelection(x, y)
741	case 2:
742		// Double click - select word
743		m.listCmp.SelectWord(x, y)
744	case 3:
745		// Triple click - select paragraph
746		m.listCmp.SelectParagraph(x, y)
747		m.clickCount = 0 // Reset after triple click
748	}
749
750	return nil
751}
752
753// SelectionClear clears the current selection in the list component.
754func (m *messageListCmp) SelectionClear() tea.Cmd {
755	m.listCmp.SelectionClear()
756	m.previousSelected = ""
757	m.lastClickX, m.lastClickY = 0, 0
758	m.lastClickTime = time.Time{}
759	m.clickCount = 0
760	return nil
761}
762
763// HasSelection checks if there is a selection in the list component.
764func (m *messageListCmp) HasSelection() bool {
765	return m.listCmp.HasSelection()
766}
767
768// GetSelectedText returns the currently selected text from the list component.
769func (m *messageListCmp) GetSelectedText() string {
770	return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
771}
772
773// CopySelectedText copies the currently selected text to the clipboard. When
774// clear is true, it clears the selection after copying.
775func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
776	if !m.listCmp.HasSelection() {
777		return nil
778	}
779
780	selectedText := m.GetSelectedText()
781	if selectedText == "" {
782		return util.ReportInfo("No text selected")
783	}
784
785	if clear {
786		defer func() { m.SelectionClear() }()
787	}
788
789	return tea.Sequence(
790		// We use both OSC 52 and native clipboard for compatibility with different
791		// terminal emulators and environments.
792		tea.SetClipboard(selectedText),
793		func() tea.Msg {
794			_ = clipboard.WriteAll(selectedText)
795			return nil
796		},
797		util.ReportInfo("Selected text copied to clipboard"),
798	)
799}
800
801// abs returns the absolute value of an integer.
802func abs(x int) int {
803	if x < 0 {
804		return -x
805	}
806	return x
807}