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