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