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