chat.go

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