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