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