chat.go

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