chat.go

  1package model
  2
  3import (
  4	"strings"
  5	"time"
  6
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9	"github.com/charmbracelet/crush/internal/ui/anim"
 10	"github.com/charmbracelet/crush/internal/ui/chat"
 11	"github.com/charmbracelet/crush/internal/ui/common"
 12	"github.com/charmbracelet/crush/internal/ui/list"
 13	uv "github.com/charmbracelet/ultraviolet"
 14	"github.com/charmbracelet/x/ansi"
 15	"github.com/clipperhouse/displaywidth"
 16	"github.com/clipperhouse/uax29/v2/words"
 17)
 18
 19// Constants for multi-click detection.
 20const (
 21	doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
 22	clickTolerance       = 2                      // x,y tolerance for double/tripple click
 23)
 24
 25// DelayedClickMsg is sent after the double-click threshold to trigger a
 26// single-click action (like expansion) if no double-click occurred.
 27type DelayedClickMsg struct {
 28	ClickID int
 29	ItemIdx int
 30	X, Y    int
 31}
 32
 33// Chat represents the chat UI model that handles chat interactions and
 34// messages.
 35type Chat struct {
 36	com      *common.Common
 37	list     *list.List
 38	idInxMap map[string]int // Map of message IDs to their indices in the list
 39
 40	// Animation visibility optimization: track animations paused due to items
 41	// being scrolled out of view. When items become visible again, their
 42	// animations are restarted.
 43	pausedAnimations map[string]struct{}
 44
 45	// Mouse state
 46	mouseDown     bool
 47	mouseDownItem int // Item index where mouse was pressed
 48	mouseDownX    int // X position in item content (character offset)
 49	mouseDownY    int // Y position in item (line offset)
 50	mouseDragItem int // Current item index being dragged over
 51	mouseDragX    int // Current X in item content
 52	mouseDragY    int // Current Y in item
 53
 54	// Click tracking for double/triple clicks
 55	lastClickTime time.Time
 56	lastClickX    int
 57	lastClickY    int
 58	clickCount    int
 59
 60	// Pending single click action (delayed to detect double-click)
 61	pendingClickID int // Incremented on each click to invalidate old pending clicks
 62
 63	// follow is a flag to indicate whether the view should auto-scroll to
 64	// bottom on new messages.
 65	follow bool
 66}
 67
 68// NewChat creates a new instance of [Chat] that handles chat interactions and
 69// messages.
 70func NewChat(com *common.Common) *Chat {
 71	c := &Chat{
 72		com:              com,
 73		idInxMap:         make(map[string]int),
 74		pausedAnimations: make(map[string]struct{}),
 75	}
 76	l := list.NewList()
 77	l.SetGap(1)
 78	l.RegisterRenderCallback(c.applyHighlightRange)
 79	l.RegisterRenderCallback(list.FocusedRenderCallback(l))
 80	c.list = l
 81	c.mouseDownItem = -1
 82	c.mouseDragItem = -1
 83	return c
 84}
 85
 86// Height returns the height of the chat view port.
 87func (m *Chat) Height() int {
 88	return m.list.Height()
 89}
 90
 91// Draw renders the chat UI component to the screen and the given area.
 92func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
 93	uv.NewStyledString(m.list.Render()).Draw(scr, area)
 94}
 95
 96// SetSize sets the size of the chat view port.
 97func (m *Chat) SetSize(width, height int) {
 98	m.list.SetSize(width, height)
 99	// Anchor to bottom if we were at the bottom.
100	if m.AtBottom() {
101		m.ScrollToBottom()
102	}
103}
104
105// Len returns the number of items in the chat list.
106func (m *Chat) Len() int {
107	return m.list.Len()
108}
109
110// SetMessages sets the chat messages to the provided list of message items.
111func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
112	m.idInxMap = make(map[string]int)
113	m.pausedAnimations = make(map[string]struct{})
114
115	items := make([]list.Item, len(msgs))
116	for i, msg := range msgs {
117		m.idInxMap[msg.ID()] = i
118		// Register nested tool IDs for tools that contain nested tools.
119		if container, ok := msg.(chat.NestedToolContainer); ok {
120			for _, nested := range container.NestedTools() {
121				m.idInxMap[nested.ID()] = i
122			}
123		}
124		items[i] = msg
125	}
126	m.list.SetItems(items...)
127	m.ScrollToBottom()
128}
129
130// AppendMessages appends a new message item to the chat list.
131func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
132	items := make([]list.Item, len(msgs))
133	indexOffset := m.list.Len()
134	for i, msg := range msgs {
135		m.idInxMap[msg.ID()] = indexOffset + i
136		// Register nested tool IDs for tools that contain nested tools.
137		if container, ok := msg.(chat.NestedToolContainer); ok {
138			for _, nested := range container.NestedTools() {
139				m.idInxMap[nested.ID()] = indexOffset + i
140			}
141		}
142		items[i] = msg
143	}
144	m.list.AppendItems(items...)
145}
146
147// UpdateNestedToolIDs updates the ID map for nested tools within a container.
148// Call this after modifying nested tools to ensure animations work correctly.
149func (m *Chat) UpdateNestedToolIDs(containerID string) {
150	idx, ok := m.idInxMap[containerID]
151	if !ok {
152		return
153	}
154
155	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
156	if !ok {
157		return
158	}
159
160	container, ok := item.(chat.NestedToolContainer)
161	if !ok {
162		return
163	}
164
165	// Register all nested tool IDs to point to the container's index.
166	for _, nested := range container.NestedTools() {
167		m.idInxMap[nested.ID()] = idx
168	}
169}
170
171// Animate animates items in the chat list. Only propagates animation messages
172// to visible items to save CPU. When items are not visible, their animation ID
173// is tracked so it can be restarted when they become visible again.
174func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
175	idx, ok := m.idInxMap[msg.ID]
176	if !ok {
177		return nil
178	}
179
180	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
181	if !ok {
182		return nil
183	}
184
185	// Check if item is currently visible.
186	startIdx, endIdx := m.list.VisibleItemIndices()
187	isVisible := idx >= startIdx && idx <= endIdx
188
189	if !isVisible {
190		// Item not visible - pause animation by not propagating.
191		// Track it so we can restart when it becomes visible.
192		m.pausedAnimations[msg.ID] = struct{}{}
193		return nil
194	}
195
196	// Item is visible - remove from paused set and animate.
197	delete(m.pausedAnimations, msg.ID)
198	return animatable.Animate(msg)
199}
200
201// RestartPausedVisibleAnimations restarts animations for items that were paused
202// due to being scrolled out of view but are now visible again.
203func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
204	if len(m.pausedAnimations) == 0 {
205		return nil
206	}
207
208	startIdx, endIdx := m.list.VisibleItemIndices()
209	var cmds []tea.Cmd
210
211	for id := range m.pausedAnimations {
212		idx, ok := m.idInxMap[id]
213		if !ok {
214			// Item no longer exists.
215			delete(m.pausedAnimations, id)
216			continue
217		}
218
219		if idx >= startIdx && idx <= endIdx {
220			// Item is now visible - restart its animation.
221			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
222				if cmd := animatable.StartAnimation(); cmd != nil {
223					cmds = append(cmds, cmd)
224				}
225			}
226			delete(m.pausedAnimations, id)
227		}
228	}
229
230	if len(cmds) == 0 {
231		return nil
232	}
233	return tea.Batch(cmds...)
234}
235
236// Focus sets the focus state of the chat component.
237func (m *Chat) Focus() {
238	m.list.Focus()
239}
240
241// Blur removes the focus state from the chat component.
242func (m *Chat) Blur() {
243	m.list.Blur()
244}
245
246// AtBottom returns whether the chat list is currently scrolled to the bottom.
247func (m *Chat) AtBottom() bool {
248	return m.list.AtBottom()
249}
250
251// Follow returns whether the chat view is in follow mode (auto-scroll to
252// bottom on new messages).
253func (m *Chat) Follow() bool {
254	return m.follow
255}
256
257// ScrollToBottom scrolls the chat view to the bottom.
258func (m *Chat) ScrollToBottom() {
259	m.list.ScrollToBottom()
260	m.follow = true // Enable follow mode when user scrolls to bottom
261}
262
263// ScrollToTop scrolls the chat view to the top.
264func (m *Chat) ScrollToTop() {
265	m.list.ScrollToTop()
266	m.follow = false // Disable follow mode when user scrolls up
267}
268
269// ScrollBy scrolls the chat view by the given number of line deltas.
270func (m *Chat) ScrollBy(lines int) {
271	m.list.ScrollBy(lines)
272	m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
273}
274
275// ScrollToSelected scrolls the chat view to the selected item.
276func (m *Chat) ScrollToSelected() {
277	m.list.ScrollToSelected()
278	m.follow = m.AtBottom() // Disable follow mode if user scrolls up
279}
280
281// ScrollToIndex scrolls the chat view to the item at the given index.
282func (m *Chat) ScrollToIndex(index int) {
283	m.list.ScrollToIndex(index)
284	m.follow = m.AtBottom() // Disable follow mode if user scrolls up
285}
286
287// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
288// any paused animations that are now visible.
289func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
290	m.ScrollToTop()
291	return m.RestartPausedVisibleAnimations()
292}
293
294// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
295// restart any paused animations that are now visible.
296func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
297	m.ScrollToBottom()
298	return m.RestartPausedVisibleAnimations()
299}
300
301// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
302// a command to restart any paused animations that are now visible.
303func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
304	m.ScrollBy(lines)
305	return m.RestartPausedVisibleAnimations()
306}
307
308// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
309// command to restart any paused animations that are now visible.
310func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
311	m.ScrollToSelected()
312	return m.RestartPausedVisibleAnimations()
313}
314
315// SelectedItemInView returns whether the selected item is currently in view.
316func (m *Chat) SelectedItemInView() bool {
317	return m.list.SelectedItemInView()
318}
319
320func (m *Chat) isSelectable(index int) bool {
321	item := m.list.ItemAt(index)
322	if item == nil {
323		return false
324	}
325	_, ok := item.(list.Focusable)
326	return ok
327}
328
329// SetSelected sets the selected message index in the chat list.
330func (m *Chat) SetSelected(index int) {
331	m.list.SetSelected(index)
332	if index < 0 || index >= m.list.Len() {
333		return
334	}
335	for {
336		if m.isSelectable(m.list.Selected()) {
337			return
338		}
339		if m.list.SelectNext() {
340			continue
341		}
342		// If we're at the end and the last item isn't selectable, walk backwards
343		// to find the nearest selectable item.
344		for {
345			if !m.list.SelectPrev() {
346				return
347			}
348			if m.isSelectable(m.list.Selected()) {
349				return
350			}
351		}
352	}
353}
354
355// SelectPrev selects the previous message in the chat list.
356func (m *Chat) SelectPrev() {
357	for {
358		if !m.list.SelectPrev() {
359			return
360		}
361		if m.isSelectable(m.list.Selected()) {
362			return
363		}
364	}
365}
366
367// SelectNext selects the next message in the chat list.
368func (m *Chat) SelectNext() {
369	for {
370		if !m.list.SelectNext() {
371			return
372		}
373		if m.isSelectable(m.list.Selected()) {
374			return
375		}
376	}
377}
378
379// SelectFirst selects the first message in the chat list.
380func (m *Chat) SelectFirst() {
381	if !m.list.SelectFirst() {
382		return
383	}
384	if m.isSelectable(m.list.Selected()) {
385		return
386	}
387	for {
388		if !m.list.SelectNext() {
389			return
390		}
391		if m.isSelectable(m.list.Selected()) {
392			return
393		}
394	}
395}
396
397// SelectLast selects the last message in the chat list.
398func (m *Chat) SelectLast() {
399	if !m.list.SelectLast() {
400		return
401	}
402	if m.isSelectable(m.list.Selected()) {
403		return
404	}
405	for {
406		if !m.list.SelectPrev() {
407			return
408		}
409		if m.isSelectable(m.list.Selected()) {
410			return
411		}
412	}
413}
414
415// SelectFirstInView selects the first message currently in view.
416func (m *Chat) SelectFirstInView() {
417	startIdx, endIdx := m.list.VisibleItemIndices()
418	for i := startIdx; i <= endIdx; i++ {
419		if m.isSelectable(i) {
420			m.list.SetSelected(i)
421			return
422		}
423	}
424}
425
426// SelectLastInView selects the last message currently in view.
427func (m *Chat) SelectLastInView() {
428	startIdx, endIdx := m.list.VisibleItemIndices()
429	for i := endIdx; i >= startIdx; i-- {
430		if m.isSelectable(i) {
431			m.list.SetSelected(i)
432			return
433		}
434	}
435}
436
437// ClearMessages removes all messages from the chat list.
438func (m *Chat) ClearMessages() {
439	m.idInxMap = make(map[string]int)
440	m.pausedAnimations = make(map[string]struct{})
441	m.list.SetItems()
442	m.ClearMouse()
443}
444
445// RemoveMessage removes a message from the chat list by its ID.
446func (m *Chat) RemoveMessage(id string) {
447	idx, ok := m.idInxMap[id]
448	if !ok {
449		return
450	}
451
452	// Remove from list
453	m.list.RemoveItem(idx)
454
455	// Remove from index map
456	delete(m.idInxMap, id)
457
458	// Rebuild index map for all items after the removed one
459	for i := idx; i < m.list.Len(); i++ {
460		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
461			m.idInxMap[item.ID()] = i
462		}
463	}
464
465	// Clean up any paused animations for this message
466	delete(m.pausedAnimations, id)
467}
468
469// MessageItem returns the message item with the given ID, or nil if not found.
470func (m *Chat) MessageItem(id string) chat.MessageItem {
471	idx, ok := m.idInxMap[id]
472	if !ok {
473		return nil
474	}
475	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
476	if !ok {
477		return nil
478	}
479	return item
480}
481
482// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
483func (m *Chat) ToggleExpandedSelectedItem() {
484	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
485		if !expandable.ToggleExpanded() {
486			m.ScrollToIndex(m.list.Selected())
487		}
488		if m.AtBottom() {
489			m.ScrollToBottom()
490		}
491	}
492}
493
494// HandleKeyMsg handles key events for the chat component.
495func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
496	if m.list.Focused() {
497		if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
498			return handler.HandleKeyEvent(key)
499		}
500	}
501	return false, nil
502}
503
504// HandleMouseDown handles mouse down events for the chat component.
505// It detects single, double, and triple clicks for text selection.
506// Returns whether the click was handled and an optional command for delayed
507// single-click actions.
508func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
509	if m.list.Len() == 0 {
510		return false, nil
511	}
512
513	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
514	if itemIdx < 0 {
515		return false, nil
516	}
517	if !m.isSelectable(itemIdx) {
518		return false, nil
519	}
520
521	// Increment pending click ID to invalidate any previous pending clicks.
522	m.pendingClickID++
523	clickID := m.pendingClickID
524
525	// Detect multi-click (double/triple)
526	now := time.Now()
527	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
528		abs(x-m.lastClickX) <= clickTolerance &&
529		abs(y-m.lastClickY) <= clickTolerance {
530		m.clickCount++
531	} else {
532		m.clickCount = 1
533	}
534	m.lastClickTime = now
535	m.lastClickX = x
536	m.lastClickY = y
537
538	// Select the item that was clicked
539	m.list.SetSelected(itemIdx)
540
541	var cmd tea.Cmd
542
543	switch m.clickCount {
544	case 1:
545		// Single click - start selection and schedule delayed click action.
546		m.mouseDown = true
547		m.mouseDownItem = itemIdx
548		m.mouseDownX = x
549		m.mouseDownY = itemY
550		m.mouseDragItem = itemIdx
551		m.mouseDragX = x
552		m.mouseDragY = itemY
553
554		// Schedule delayed click action (e.g., expansion) after a short delay.
555		// If a double-click occurs, the clickID will be invalidated.
556		cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
557			return DelayedClickMsg{
558				ClickID: clickID,
559				ItemIdx: itemIdx,
560				X:       x,
561				Y:       itemY,
562			}
563		})
564	case 2:
565		// Double click - select word (no delayed action)
566		m.selectWord(itemIdx, x, itemY)
567	case 3:
568		// Triple click - select line (no delayed action)
569		m.selectLine(itemIdx, itemY)
570		m.clickCount = 0 // Reset after triple click
571	}
572
573	return true, cmd
574}
575
576// HandleDelayedClick handles a delayed single-click action (like expansion).
577// It only executes if the click ID matches (i.e., no double-click occurred)
578// and no text selection was made (drag to select).
579func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
580	// Ignore if this click was superseded by a newer click (double/triple).
581	if msg.ClickID != m.pendingClickID {
582		return false
583	}
584
585	// Don't expand if user dragged to select text.
586	if m.HasHighlight() {
587		return false
588	}
589
590	// Execute the click action (e.g., expansion).
591	selectedItem := m.list.SelectedItem()
592	if clickable, ok := selectedItem.(list.MouseClickable); ok {
593		handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
594		// Toggle expansion if applicable.
595		if expandable, ok := selectedItem.(chat.Expandable); ok {
596			if !expandable.ToggleExpanded() {
597				m.ScrollToIndex(m.list.Selected())
598			}
599		}
600		if m.AtBottom() {
601			m.ScrollToBottom()
602		}
603		return handled
604	}
605
606	return false
607}
608
609// HandleMouseUp handles mouse up events for the chat component.
610func (m *Chat) HandleMouseUp(x, y int) bool {
611	if !m.mouseDown {
612		return false
613	}
614
615	m.mouseDown = false
616	return true
617}
618
619// HandleMouseDrag handles mouse drag events for the chat component.
620func (m *Chat) HandleMouseDrag(x, y int) bool {
621	if !m.mouseDown {
622		return false
623	}
624
625	if m.list.Len() == 0 {
626		return false
627	}
628
629	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
630	if itemIdx < 0 {
631		return false
632	}
633
634	m.mouseDragItem = itemIdx
635	m.mouseDragX = x
636	m.mouseDragY = itemY
637
638	return true
639}
640
641// HasHighlight returns whether there is currently highlighted content.
642func (m *Chat) HasHighlight() bool {
643	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
644	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
645}
646
647// HighlightContent returns the currently highlighted content based on the mouse
648// selection. It returns an empty string if no content is highlighted.
649func (m *Chat) HighlightContent() string {
650	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
651	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
652		return ""
653	}
654
655	var sb strings.Builder
656	for i := startItemIdx; i <= endItemIdx; i++ {
657		item := m.list.ItemAt(i)
658		if hi, ok := item.(list.Highlightable); ok {
659			startLine, startCol, endLine, endCol := hi.Highlight()
660			listWidth := m.list.Width()
661			var rendered string
662			if rr, ok := item.(list.RawRenderable); ok {
663				rendered = rr.RawRender(listWidth)
664			} else {
665				rendered = item.Render(listWidth)
666			}
667			sb.WriteString(list.HighlightContent(
668				rendered,
669				uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
670				startLine,
671				startCol,
672				endLine,
673				endCol,
674			))
675			sb.WriteString(strings.Repeat("\n", m.list.Gap()))
676		}
677	}
678
679	return strings.TrimSpace(sb.String())
680}
681
682// ClearMouse clears the current mouse interaction state.
683func (m *Chat) ClearMouse() {
684	m.mouseDown = false
685	m.mouseDownItem = -1
686	m.mouseDragItem = -1
687	m.lastClickTime = time.Time{}
688	m.lastClickX = 0
689	m.lastClickY = 0
690	m.clickCount = 0
691	m.pendingClickID++ // Invalidate any pending delayed click
692}
693
694// applyHighlightRange applies the current highlight range to the chat items.
695func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
696	if hi, ok := item.(list.Highlightable); ok {
697		// Apply highlight
698		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
699		sLine, sCol, eLine, eCol := -1, -1, -1, -1
700		if idx >= startItemIdx && idx <= endItemIdx {
701			if idx == startItemIdx && idx == endItemIdx {
702				// Single item selection
703				sLine = startLine
704				sCol = startCol
705				eLine = endLine
706				eCol = endCol
707			} else if idx == startItemIdx {
708				// First item - from start position to end of item
709				sLine = startLine
710				sCol = startCol
711				eLine = -1
712				eCol = -1
713			} else if idx == endItemIdx {
714				// Last item - from start of item to end position
715				sLine = 0
716				sCol = 0
717				eLine = endLine
718				eCol = endCol
719			} else {
720				// Middle item - fully highlighted
721				sLine = 0
722				sCol = 0
723				eLine = -1
724				eCol = -1
725			}
726		}
727
728		hi.SetHighlight(sLine, sCol, eLine, eCol)
729		return hi.(list.Item)
730	}
731
732	return item
733}
734
735// getHighlightRange returns the current highlight range.
736func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
737	if m.mouseDownItem < 0 {
738		return -1, -1, -1, -1, -1, -1
739	}
740
741	downItemIdx := m.mouseDownItem
742	dragItemIdx := m.mouseDragItem
743
744	// Determine selection direction
745	draggingDown := dragItemIdx > downItemIdx ||
746		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
747		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
748
749	if draggingDown {
750		// Normal forward selection
751		startItemIdx = downItemIdx
752		startLine = m.mouseDownY
753		startCol = m.mouseDownX
754		endItemIdx = dragItemIdx
755		endLine = m.mouseDragY
756		endCol = m.mouseDragX
757	} else {
758		// Backward selection (dragging up)
759		startItemIdx = dragItemIdx
760		startLine = m.mouseDragY
761		startCol = m.mouseDragX
762		endItemIdx = downItemIdx
763		endLine = m.mouseDownY
764		endCol = m.mouseDownX
765	}
766
767	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
768}
769
770// selectWord selects the word at the given position within an item.
771func (m *Chat) selectWord(itemIdx, x, itemY int) {
772	item := m.list.ItemAt(itemIdx)
773	if item == nil {
774		return
775	}
776
777	// Get the rendered content for this item
778	var rendered string
779	if rr, ok := item.(list.RawRenderable); ok {
780		rendered = rr.RawRender(m.list.Width())
781	} else {
782		rendered = item.Render(m.list.Width())
783	}
784
785	lines := strings.Split(rendered, "\n")
786	if itemY < 0 || itemY >= len(lines) {
787		return
788	}
789
790	// Adjust x for the item's left padding (border + padding) to get content column.
791	// The mouse x is in viewport space, but we need content space for boundary detection.
792	offset := chat.MessageLeftPaddingTotal
793	contentX := max(x-offset, 0)
794
795	line := ansi.Strip(lines[itemY])
796	startCol, endCol := findWordBoundaries(line, contentX)
797	if startCol == endCol {
798		// No word found at position, fallback to single click behavior
799		m.mouseDown = true
800		m.mouseDownItem = itemIdx
801		m.mouseDownX = x
802		m.mouseDownY = itemY
803		m.mouseDragItem = itemIdx
804		m.mouseDragX = x
805		m.mouseDragY = itemY
806		return
807	}
808
809	// Set selection to the word boundaries (convert back to viewport space).
810	// Keep mouseDown true so HandleMouseUp triggers the copy.
811	m.mouseDown = true
812	m.mouseDownItem = itemIdx
813	m.mouseDownX = startCol + offset
814	m.mouseDownY = itemY
815	m.mouseDragItem = itemIdx
816	m.mouseDragX = endCol + offset
817	m.mouseDragY = itemY
818}
819
820// selectLine selects the entire line at the given position within an item.
821func (m *Chat) selectLine(itemIdx, itemY int) {
822	item := m.list.ItemAt(itemIdx)
823	if item == nil {
824		return
825	}
826
827	// Get the rendered content for this item
828	var rendered string
829	if rr, ok := item.(list.RawRenderable); ok {
830		rendered = rr.RawRender(m.list.Width())
831	} else {
832		rendered = item.Render(m.list.Width())
833	}
834
835	lines := strings.Split(rendered, "\n")
836	if itemY < 0 || itemY >= len(lines) {
837		return
838	}
839
840	// Get line length (stripped of ANSI codes) and account for padding.
841	// SetHighlight will subtract the offset, so we need to add it here.
842	offset := chat.MessageLeftPaddingTotal
843	lineLen := ansi.StringWidth(lines[itemY])
844
845	// Set selection to the entire line.
846	// Keep mouseDown true so HandleMouseUp triggers the copy.
847	m.mouseDown = true
848	m.mouseDownItem = itemIdx
849	m.mouseDownX = 0
850	m.mouseDownY = itemY
851	m.mouseDragItem = itemIdx
852	m.mouseDragX = lineLen + offset
853	m.mouseDragY = itemY
854}
855
856// findWordBoundaries finds the start and end column of the word at the given column.
857// Returns (startCol, endCol) where endCol is exclusive.
858func findWordBoundaries(line string, col int) (startCol, endCol int) {
859	if line == "" || col < 0 {
860		return 0, 0
861	}
862
863	i := displaywidth.StringGraphemes(line)
864	for i.Next() {
865	}
866
867	// Segment the line into words using UAX#29.
868	lineCol := 0 // tracks the visited column widths
869	lastCol := 0 // tracks the start of the current token
870	iter := words.FromString(line)
871	for iter.Next() {
872		token := iter.Value()
873		tokenWidth := displaywidth.String(token)
874
875		graphemeStart := lineCol
876		graphemeEnd := lineCol + tokenWidth
877		lineCol += tokenWidth
878
879		// If clicked before this token, return the previous token boundaries.
880		if col < graphemeStart {
881			return lastCol, lastCol
882		}
883
884		// Update lastCol to the end of this token for next iteration.
885		lastCol = graphemeEnd
886
887		// If clicked within this token, return its boundaries.
888		if col >= graphemeStart && col < graphemeEnd {
889			// If clicked on whitespace, return empty selection.
890			if strings.TrimSpace(token) == "" {
891				return col, col
892			}
893			return graphemeStart, graphemeEnd
894		}
895	}
896
897	return col, col
898}
899
900// abs returns the absolute value of an integer.
901func abs(x int) int {
902	if x < 0 {
903		return -x
904	}
905	return x
906}