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