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