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