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