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