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