chat.go

  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}