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}