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