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}