@@ -2,6 +2,7 @@ package model
6 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
7 "charm.land/lipgloss/v2"
8 "charm.land/lipgloss/v2"
@@ -11,8 +12,24 @@ import (
11 "github.com/charmbracelet/crush/internal/ui/list"
12 "github.com/charmbracelet/crush/internal/ui/list"
12 uv "github.com/charmbracelet/ultraviolet"
13 uv "github.com/charmbracelet/ultraviolet"
13 "github.com/charmbracelet/x/ansi"
14 "github.com/charmbracelet/x/ansi"
15+ "github.com/clipperhouse/displaywidth"
16+ "github.com/clipperhouse/uax29/v2/words"
19+// Constants for multi-click detection.
21+ doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
22+ clickTolerance = 2 // x,y tolerance for double/tripple click
25+// DelayedClickMsg is sent after the double-click threshold to trigger a
26+// single-click action (like expansion) if no double-click occurred.
27+type DelayedClickMsg struct {
16// Chat represents the chat UI model that handles chat interactions and
33// Chat represents the chat UI model that handles chat interactions and
17// messages.
34// messages.
18type Chat struct {
35type Chat struct {
@@ -33,6 +50,15 @@ type Chat struct {
33 mouseDragItem int // Current item index being dragged over
50 mouseDragItem int // Current item index being dragged over
34 mouseDragX int // Current X in item content
51 mouseDragX int // Current X in item content
35 mouseDragY int // Current Y in item
52 mouseDragY int // Current Y in item
54+ // Click tracking for double/triple clicks
55+ lastClickTime time.Time
60+ // Pending single click action (delayed to detect double-click)
61+ pendingClickID int // Incremented on each click to invalidate old pending clicks
38// NewChat creates a new instance of [Chat] that handles chat interactions and
64// NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -426,35 +452,97 @@ func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
428// HandleMouseDown handles mouse down events for the chat component.
454// HandleMouseDown handles mouse down events for the chat component.
455+// It detects single, double, and triple clicks for text selection.
456+// Returns whether the click was handled and an optional command for delayed
457+// single-click actions.
429−func (m *Chat) HandleMouseDown(x, y int) bool {
458+func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
430 if m.list.Len() == 0 {
459 if m.list.Len() == 0 {
431− return false
460+ return false, nil
434 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
463 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
435 if itemIdx < 0 {
464 if itemIdx < 0 {
436− return false
465+ return false, nil
438 if !m.isSelectable(itemIdx) {
467 if !m.isSelectable(itemIdx) {
439− return false
468+ return false, nil
442− m.mouseDown = true
471+ // Increment pending click ID to invalidate any previous pending clicks.
443− m.mouseDownItem = itemIdx
472+ m.pendingClickID++
444− m.mouseDownX = x
473+ clickID := m.pendingClickID
445− m.mouseDownY = itemY
474+
446− m.mouseDragItem = itemIdx
475+ // Detect multi-click (double/triple)
447− m.mouseDragX = x
476+ now := time.Now()
448− m.mouseDragY = itemY
477+ if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
478+ abs(x-m.lastClickX) <= clickTolerance &&
479+ abs(y-m.lastClickY) <= clickTolerance {
484+ m.lastClickTime = now
450 // Select the item that was clicked
488 // Select the item that was clicked
451 m.list.SetSelected(itemIdx)
489 m.list.SetSelected(itemIdx)
493+ switch m.clickCount {
495+ // Single click - start selection and schedule delayed click action.
497+ m.mouseDownItem = itemIdx
499+ m.mouseDownY = itemY
500+ m.mouseDragItem = itemIdx
502+ m.mouseDragY = itemY
504+ // Schedule delayed click action (e.g., expansion) after a short delay.
505+ // If a double-click occurs, the clickID will be invalidated.
506+ cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
507+ return DelayedClickMsg{
515+ // Double click - select word (no delayed action)
516+ m.selectWord(itemIdx, x, itemY)
518+ // Triple click - select line (no delayed action)
519+ m.selectLine(itemIdx, itemY)
520+ m.clickCount = 0 // Reset after triple click
526+// HandleDelayedClick handles a delayed single-click action (like expansion).
527+// It only executes if the click ID matches (i.e., no double-click occurred)
528+// and no text selection was made (drag to select).
529+func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
530+ // Ignore if this click was superseded by a newer click (double/triple).
531+ if msg.ClickID != m.pendingClickID {
535+ // Don't expand if user dragged to select text.
536+ if m.HasHighlight() {
540+ // Execute the click action (e.g., expansion).
453 if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
541 if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
454− return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
542+ return clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
457− return true
545+ return false
460// HandleMouseUp handles mouse up events for the chat component.
548// HandleMouseUp handles mouse up events for the chat component.
@@ -535,6 +623,11 @@ func (m *Chat) ClearMouse() {
535 m.mouseDown = false
623 m.mouseDown = false
536 m.mouseDownItem = -1
624 m.mouseDownItem = -1
537 m.mouseDragItem = -1
625 m.mouseDragItem = -1
626+ m.lastClickTime = time.Time{}
630+ m.pendingClickID++ // Invalidate any pending delayed click
540// applyHighlightRange applies the current highlight range to the chat items.
633// applyHighlightRange applies the current highlight range to the chat items.
@@ -612,3 +705,144 @@ func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemId
613 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
706 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
709+// selectWord selects the word at the given position within an item.
710+func (m *Chat) selectWord(itemIdx, x, itemY int) {
711+ item := m.list.ItemAt(itemIdx)
716+ // Get the rendered content for this item
718+ if rr, ok := item.(list.RawRenderable); ok {
719+ rendered = rr.RawRender(m.list.Width())
721+ rendered = item.Render(m.list.Width())
724+ lines := strings.Split(rendered, "\n")
725+ if itemY < 0 || itemY >= len(lines) {
729+ // Adjust x for the item's left padding (border + padding) to get content column.
730+ // The mouse x is in viewport space, but we need content space for boundary detection.
731+ offset := chat.MessageLeftPaddingTotal
732+ contentX := x - offset
737+ line := ansi.Strip(lines[itemY])
738+ startCol, endCol := findWordBoundaries(line, contentX)
739+ if startCol == endCol {
740+ // No word found at position, fallback to single click behavior
742+ m.mouseDownItem = itemIdx
744+ m.mouseDownY = itemY
745+ m.mouseDragItem = itemIdx
747+ m.mouseDragY = itemY
751+ // Set selection to the word boundaries (convert back to viewport space).
752+ // Keep mouseDown true so HandleMouseUp triggers the copy.
754+ m.mouseDownItem = itemIdx
755+ m.mouseDownX = startCol + offset
756+ m.mouseDownY = itemY
757+ m.mouseDragItem = itemIdx
758+ m.mouseDragX = endCol + offset
759+ m.mouseDragY = itemY
762+// selectLine selects the entire line at the given position within an item.
763+func (m *Chat) selectLine(itemIdx, itemY int) {
764+ item := m.list.ItemAt(itemIdx)
769+ // Get the rendered content for this item
771+ if rr, ok := item.(list.RawRenderable); ok {
772+ rendered = rr.RawRender(m.list.Width())
774+ rendered = item.Render(m.list.Width())
777+ lines := strings.Split(rendered, "\n")
778+ if itemY < 0 || itemY >= len(lines) {
782+ // Get line length (stripped of ANSI codes) and account for padding.
783+ // SetHighlight will subtract the offset, so we need to add it here.
784+ offset := chat.MessageLeftPaddingTotal
785+ lineLen := ansi.StringWidth(lines[itemY])
787+ // Set selection to the entire line.
788+ // Keep mouseDown true so HandleMouseUp triggers the copy.
790+ m.mouseDownItem = itemIdx
792+ m.mouseDownY = itemY
793+ m.mouseDragItem = itemIdx
794+ m.mouseDragX = lineLen + offset
795+ m.mouseDragY = itemY
798+// findWordBoundaries finds the start and end column of the word at the given column.
799+// Returns (startCol, endCol) where endCol is exclusive.
800+func findWordBoundaries(line string, col int) (startCol, endCol int) {
801+ if line == "" || col < 0 {
805+ i := displaywidth.StringGraphemes(line)
809+ // Segment the line into words using UAX#29.
810+ lineCol := 0 // tracks the visited column widths
811+ lastCol := 0 // tracks the start of the current token
812+ iter := words.FromString(line)
814+ token := iter.Value()
815+ tokenWidth := displaywidth.String(token)
817+ graphemeStart := lineCol
818+ graphemeEnd := lineCol + tokenWidth
819+ lineCol += tokenWidth
821+ // If clicked before this token, return the previous token boundaries.
822+ if col < graphemeStart {
823+ return lastCol, lastCol
826+ // Update lastCol to the end of this token for next iteration.
827+ lastCol = graphemeEnd
829+ // If clicked within this token, return its boundaries.
830+ if col >= graphemeStart && col < graphemeEnd {
831+ // If clicked on whitespace, return empty selection.
832+ if strings.TrimSpace(token) == "" {
835+ return graphemeStart, graphemeEnd
842+// abs returns the absolute value of an integer.
843+func abs(x int) int {