refactor(chat): handle double click & triple click (#1959)

Kujtim Hoxha and Ayman Bagabas created

* refactor(chat): handle double click & tripple click

this also improves the expand behavior for items that can expand when
you click or heighlight them, now they won't expland for double click or
while you are highlighting

* chore: use uax29 words

* fix(ui): chat: simplify word boundary detection in highlighted text

* fix(ui): chat: adjust multi-click timing

* chore: go mod tidy

* chore: change double click to 400ms

---------

Co-authored-by: Ayman Bagabas <ayman.bagabas@gmail.com>

Change summary

go.mod                       |   4 
go.sum                       |   4 
internal/ui/chat/messages.go |  12 
internal/ui/chat/tools.go    |   4 
internal/ui/model/chat.go    | 260 ++++++++++++++++++++++++++++++++++++-
internal/ui/model/ui.go      |  13 +
6 files changed, 269 insertions(+), 28 deletions(-)

Detailed changes

go.mod 🔗

@@ -33,6 +33,8 @@ require (
 	github.com/charmbracelet/x/exp/strings v0.1.0
 	github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
 	github.com/charmbracelet/x/term v0.2.2
+	github.com/clipperhouse/displaywidth v0.7.0
+	github.com/clipperhouse/uax29/v2 v2.3.1
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
 	github.com/disintegration/imaging v1.6.2
@@ -106,9 +108,7 @@ require (
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
-	github.com/clipperhouse/displaywidth v0.7.0 // indirect
 	github.com/clipperhouse/stringish v0.1.1 // indirect
-	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/disintegration/gift v1.1.2 // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect

go.sum 🔗

@@ -134,8 +134,8 @@ github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBw
 github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
 github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
 github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
-github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw=
+github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
 github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
 github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=

internal/ui/chat/messages.go 🔗

@@ -18,9 +18,9 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
 
-// this is the total width that is taken up by the border + padding
-// we also cap the width so text is readable to the maxTextWidth(120)
-const messageLeftPaddingTotal = 2
+// MessageLeftPaddingTotal is the total width that is taken up by the border +
+// padding. We also cap the width so text is readable to the maxTextWidth(120).
+const MessageLeftPaddingTotal = 2
 
 // maxTextWidth is the maximum width text messages can be
 const maxTextWidth = 120
@@ -100,7 +100,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
 func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
 	// Adjust columns for the style's left inset (border + padding) since we
 	// highlight the content only.
-	offset := messageLeftPaddingTotal
+	offset := MessageLeftPaddingTotal
 	h.startLine = startLine
 	h.startCol = max(0, startCol-offset)
 	h.endLine = endLine
@@ -205,7 +205,7 @@ func (a *AssistantInfoItem) ID() string {
 
 // RawRender implements MessageItem.
 func (a *AssistantInfoItem) RawRender(width int) string {
-	innerWidth := max(0, width-messageLeftPaddingTotal)
+	innerWidth := max(0, width-MessageLeftPaddingTotal)
 	content, _, ok := a.getCachedRender(innerWidth)
 	if !ok {
 		content = a.renderContent(innerWidth)
@@ -245,7 +245,7 @@ func (a *AssistantInfoItem) renderContent(width int) string {
 
 // cappedMessageWidth returns the maximum width for message content for readability.
 func cappedMessageWidth(availableWidth int) int {
-	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
+	return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth)
 }
 
 // ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It

internal/ui/chat/tools.go 🔗

@@ -292,7 +292,7 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
 
 // RawRender implements [MessageItem].
 func (t *baseToolMessageItem) RawRender(width int) string {
-	toolItemWidth := width - messageLeftPaddingTotal
+	toolItemWidth := width - MessageLeftPaddingTotal
 	if t.hasCappedWidth {
 		toolItemWidth = cappedMessageWidth(width)
 	}
@@ -690,7 +690,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
 		truncMsg := sty.Tool.DiffTruncation.
 			Width(bodyWidth).
 			Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
-		formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
+		formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
 	}
 
 	return sty.Tool.Body.Render(formatted)

internal/ui/model/chat.go 🔗

@@ -2,6 +2,7 @@ package model
 
 import (
 	"strings"
+	"time"
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
@@ -11,8 +12,24 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/clipperhouse/displaywidth"
+	"github.com/clipperhouse/uax29/v2/words"
 )
 
+// Constants for multi-click detection.
+const (
+	doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
+	clickTolerance       = 2                      // x,y tolerance for double/tripple click
+)
+
+// DelayedClickMsg is sent after the double-click threshold to trigger a
+// single-click action (like expansion) if no double-click occurred.
+type DelayedClickMsg struct {
+	ClickID int
+	ItemIdx int
+	X, Y    int
+}
+
 // Chat represents the chat UI model that handles chat interactions and
 // messages.
 type Chat struct {
@@ -33,6 +50,15 @@ type Chat struct {
 	mouseDragItem int // Current item index being dragged over
 	mouseDragX    int // Current X in item content
 	mouseDragY    int // Current Y in item
+
+	// Click tracking for double/triple clicks
+	lastClickTime time.Time
+	lastClickX    int
+	lastClickY    int
+	clickCount    int
+
+	// Pending single click action (delayed to detect double-click)
+	pendingClickID int // Incremented on each click to invalidate old pending clicks
 }
 
 // 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) {
 }
 
 // HandleMouseDown handles mouse down events for the chat component.
-func (m *Chat) HandleMouseDown(x, y int) bool {
+// It detects single, double, and triple clicks for text selection.
+// Returns whether the click was handled and an optional command for delayed
+// single-click actions.
+func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
 	if m.list.Len() == 0 {
-		return false
+		return false, nil
 	}
 
 	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
 	if itemIdx < 0 {
-		return false
+		return false, nil
 	}
 	if !m.isSelectable(itemIdx) {
-		return false
+		return false, nil
 	}
 
-	m.mouseDown = true
-	m.mouseDownItem = itemIdx
-	m.mouseDownX = x
-	m.mouseDownY = itemY
-	m.mouseDragItem = itemIdx
-	m.mouseDragX = x
-	m.mouseDragY = itemY
+	// Increment pending click ID to invalidate any previous pending clicks.
+	m.pendingClickID++
+	clickID := m.pendingClickID
+
+	// Detect multi-click (double/triple)
+	now := time.Now()
+	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
+		abs(x-m.lastClickX) <= clickTolerance &&
+		abs(y-m.lastClickY) <= clickTolerance {
+		m.clickCount++
+	} else {
+		m.clickCount = 1
+	}
+	m.lastClickTime = now
+	m.lastClickX = x
+	m.lastClickY = y
 
 	// Select the item that was clicked
 	m.list.SetSelected(itemIdx)
 
+	var cmd tea.Cmd
+
+	switch m.clickCount {
+	case 1:
+		// Single click - start selection and schedule delayed click action.
+		m.mouseDown = true
+		m.mouseDownItem = itemIdx
+		m.mouseDownX = x
+		m.mouseDownY = itemY
+		m.mouseDragItem = itemIdx
+		m.mouseDragX = x
+		m.mouseDragY = itemY
+
+		// Schedule delayed click action (e.g., expansion) after a short delay.
+		// If a double-click occurs, the clickID will be invalidated.
+		cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
+			return DelayedClickMsg{
+				ClickID: clickID,
+				ItemIdx: itemIdx,
+				X:       x,
+				Y:       itemY,
+			}
+		})
+	case 2:
+		// Double click - select word (no delayed action)
+		m.selectWord(itemIdx, x, itemY)
+	case 3:
+		// Triple click - select line (no delayed action)
+		m.selectLine(itemIdx, itemY)
+		m.clickCount = 0 // Reset after triple click
+	}
+
+	return true, cmd
+}
+
+// HandleDelayedClick handles a delayed single-click action (like expansion).
+// It only executes if the click ID matches (i.e., no double-click occurred)
+// and no text selection was made (drag to select).
+func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
+	// Ignore if this click was superseded by a newer click (double/triple).
+	if msg.ClickID != m.pendingClickID {
+		return false
+	}
+
+	// Don't expand if user dragged to select text.
+	if m.HasHighlight() {
+		return false
+	}
+
+	// Execute the click action (e.g., expansion).
 	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
-		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
+		return clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
 	}
 
-	return true
+	return false
 }
 
 // HandleMouseUp handles mouse up events for the chat component.
@@ -535,6 +623,11 @@ func (m *Chat) ClearMouse() {
 	m.mouseDown = false
 	m.mouseDownItem = -1
 	m.mouseDragItem = -1
+	m.lastClickTime = time.Time{}
+	m.lastClickX = 0
+	m.lastClickY = 0
+	m.clickCount = 0
+	m.pendingClickID++ // Invalidate any pending delayed click
 }
 
 // applyHighlightRange applies the current highlight range to the chat items.
@@ -612,3 +705,144 @@ func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemId
 
 	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
 }
+
+// selectWord selects the word at the given position within an item.
+func (m *Chat) selectWord(itemIdx, x, itemY int) {
+	item := m.list.ItemAt(itemIdx)
+	if item == nil {
+		return
+	}
+
+	// Get the rendered content for this item
+	var rendered string
+	if rr, ok := item.(list.RawRenderable); ok {
+		rendered = rr.RawRender(m.list.Width())
+	} else {
+		rendered = item.Render(m.list.Width())
+	}
+
+	lines := strings.Split(rendered, "\n")
+	if itemY < 0 || itemY >= len(lines) {
+		return
+	}
+
+	// Adjust x for the item's left padding (border + padding) to get content column.
+	// The mouse x is in viewport space, but we need content space for boundary detection.
+	offset := chat.MessageLeftPaddingTotal
+	contentX := x - offset
+	if contentX < 0 {
+		contentX = 0
+	}
+
+	line := ansi.Strip(lines[itemY])
+	startCol, endCol := findWordBoundaries(line, contentX)
+	if startCol == endCol {
+		// No word found at position, fallback to single click behavior
+		m.mouseDown = true
+		m.mouseDownItem = itemIdx
+		m.mouseDownX = x
+		m.mouseDownY = itemY
+		m.mouseDragItem = itemIdx
+		m.mouseDragX = x
+		m.mouseDragY = itemY
+		return
+	}
+
+	// Set selection to the word boundaries (convert back to viewport space).
+	// Keep mouseDown true so HandleMouseUp triggers the copy.
+	m.mouseDown = true
+	m.mouseDownItem = itemIdx
+	m.mouseDownX = startCol + offset
+	m.mouseDownY = itemY
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = endCol + offset
+	m.mouseDragY = itemY
+}
+
+// selectLine selects the entire line at the given position within an item.
+func (m *Chat) selectLine(itemIdx, itemY int) {
+	item := m.list.ItemAt(itemIdx)
+	if item == nil {
+		return
+	}
+
+	// Get the rendered content for this item
+	var rendered string
+	if rr, ok := item.(list.RawRenderable); ok {
+		rendered = rr.RawRender(m.list.Width())
+	} else {
+		rendered = item.Render(m.list.Width())
+	}
+
+	lines := strings.Split(rendered, "\n")
+	if itemY < 0 || itemY >= len(lines) {
+		return
+	}
+
+	// Get line length (stripped of ANSI codes) and account for padding.
+	// SetHighlight will subtract the offset, so we need to add it here.
+	offset := chat.MessageLeftPaddingTotal
+	lineLen := ansi.StringWidth(lines[itemY])
+
+	// Set selection to the entire line.
+	// Keep mouseDown true so HandleMouseUp triggers the copy.
+	m.mouseDown = true
+	m.mouseDownItem = itemIdx
+	m.mouseDownX = 0
+	m.mouseDownY = itemY
+	m.mouseDragItem = itemIdx
+	m.mouseDragX = lineLen + offset
+	m.mouseDragY = itemY
+}
+
+// findWordBoundaries finds the start and end column of the word at the given column.
+// Returns (startCol, endCol) where endCol is exclusive.
+func findWordBoundaries(line string, col int) (startCol, endCol int) {
+	if line == "" || col < 0 {
+		return 0, 0
+	}
+
+	i := displaywidth.StringGraphemes(line)
+	for i.Next() {
+	}
+
+	// Segment the line into words using UAX#29.
+	lineCol := 0 // tracks the visited column widths
+	lastCol := 0 // tracks the start of the current token
+	iter := words.FromString(line)
+	for iter.Next() {
+		token := iter.Value()
+		tokenWidth := displaywidth.String(token)
+
+		graphemeStart := lineCol
+		graphemeEnd := lineCol + tokenWidth
+		lineCol += tokenWidth
+
+		// If clicked before this token, return the previous token boundaries.
+		if col < graphemeStart {
+			return lastCol, lastCol
+		}
+
+		// Update lastCol to the end of this token for next iteration.
+		lastCol = graphemeEnd
+
+		// If clicked within this token, return its boundaries.
+		if col >= graphemeStart && col < graphemeEnd {
+			// If clicked on whitespace, return empty selection.
+			if strings.TrimSpace(token) == "" {
+				return col, col
+			}
+			return graphemeStart, graphemeEnd
+		}
+	}
+
+	return col, col
+}
+
+// abs returns the absolute value of an integer.
+func abs(x int) int {
+	if x < 0 {
+		return -x
+	}
+	return x
+}

internal/ui/model/ui.go 🔗

@@ -524,6 +524,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case copyChatHighlightMsg:
 		cmds = append(cmds, m.copyChatHighlight())
+	case DelayedClickMsg:
+		// Handle delayed single-click action (e.g., expansion).
+		m.chat.HandleDelayedClick(msg)
 	case tea.MouseClickMsg:
 		// Pass mouse events to dialogs first if any are open.
 		if m.dialog.HasDialogs() {
@@ -541,8 +544,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			// Adjust for chat area position
 			x -= m.layout.main.Min.X
 			y -= m.layout.main.Min.Y
-			if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) && m.chat.HandleMouseDown(x, y) {
-				m.lastClickTime = time.Now()
+			if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
+				if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
+					m.lastClickTime = time.Now()
+					if cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+				}
 			}
 		}
 
@@ -590,7 +598,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.dialog.Update(msg)
 			return m, tea.Batch(cmds...)
 		}
-		const doubleClickThreshold = 500 * time.Millisecond
 
 		switch m.state {
 		case uiChat: