chat.go

  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}