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