on main help sign in
refactor(chat): handle double click & triple click (#1959)

* 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>

commit25f05a6a84f8ee43a675d2d4154d30d18687bb31 authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 2 Feb 2026, 11:51 (9 months ago) parent514318d
6 files changed+269−28
go.mod+2−2 go.sum+2−2 internal/ui/chat/messages.go+6−6 internal/ui/chat/tools.go+2−2 internal/ui/model/chat.go+247−13 internal/ui/model/ui.go+10−3
go.mod+2−2
@@ -33,6 +33,8 @@ require (
3333 github.com/charmbracelet/x/exp/strings v0.1.0
3434 github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
3535 github.com/charmbracelet/x/term v0.2.2
36+ github.com/clipperhouse/displaywidth v0.7.0
37+ github.com/clipperhouse/uax29/v2 v2.3.1
3638 github.com/denisbrodbeck/machineid v1.0.1
3739 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
3840 github.com/disintegration/imaging v1.6.2
@@ -106,9 +108,7 @@ require (
106108 github.com/charmbracelet/x/json v0.2.0 // indirect
107109 github.com/charmbracelet/x/termios v0.1.1 // indirect
108110 github.com/charmbracelet/x/windows v0.2.2 // indirect
109− github.com/clipperhouse/displaywidth v0.7.0 // indirect
110111 github.com/clipperhouse/stringish v0.1.1 // indirect
111− github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
112112 github.com/davecgh/go-spew v1.1.1 // indirect
113113 github.com/disintegration/gift v1.1.2 // indirect
114114 github.com/dlclark/regexp2 v1.11.5 // indirect
go.sum+2−2
@@ -134,8 +134,8 @@ github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBw
134134github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
135135github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
136136github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
137−github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
138−github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
137+github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw=
138+github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
139139github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
140140github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
141141github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
internal/ui/chat/messages.go+6−6
@@ -18,9 +18,9 @@ import (
1818 "github.com/charmbracelet/crush/internal/ui/styles"
1919)
2020
21−// this is the total width that is taken up by the border + padding
22−// we also cap the width so text is readable to the maxTextWidth(120)
23−const messageLeftPaddingTotal = 2
21+// MessageLeftPaddingTotal is the total width that is taken up by the border +
22+// padding. We also cap the width so text is readable to the maxTextWidth(120).
23+const MessageLeftPaddingTotal = 2
2424
2525// maxTextWidth is the maximum width text messages can be
2626const maxTextWidth = 120
@@ -100,7 +100,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
100100func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
101101 // Adjust columns for the style's left inset (border + padding) since we
102102 // highlight the content only.
103− offset := messageLeftPaddingTotal
103+ offset := MessageLeftPaddingTotal
104104 h.startLine = startLine
105105 h.startCol = max(0, startCol-offset)
106106 h.endLine = endLine
@@ -205,7 +205,7 @@ func (a *AssistantInfoItem) ID() string {
205205
206206// RawRender implements MessageItem.
207207func (a *AssistantInfoItem) RawRender(width int) string {
208− innerWidth := max(0, width-messageLeftPaddingTotal)
208+ innerWidth := max(0, width-MessageLeftPaddingTotal)
209209 content, _, ok := a.getCachedRender(innerWidth)
210210 if !ok {
211211 content = a.renderContent(innerWidth)
@@ -245,7 +245,7 @@ func (a *AssistantInfoItem) renderContent(width int) string {
245245
246246// cappedMessageWidth returns the maximum width for message content for readability.
247247func cappedMessageWidth(availableWidth int) int {
248− return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
248+ return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth)
249249}
250250
251251// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
internal/ui/chat/tools.go+2−2
@@ -292,7 +292,7 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
292292
293293// RawRender implements [MessageItem].
294294func (t *baseToolMessageItem) RawRender(width int) string {
295− toolItemWidth := width - messageLeftPaddingTotal
295+ toolItemWidth := width - MessageLeftPaddingTotal
296296 if t.hasCappedWidth {
297297 toolItemWidth = cappedMessageWidth(width)
298298 }
@@ -690,7 +690,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
690690 truncMsg := sty.Tool.DiffTruncation.
691691 Width(bodyWidth).
692692 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
693− formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
693+ formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg
694694 }
695695
696696 return sty.Tool.Body.Render(formatted)
internal/ui/model/chat.go+247−13
@@ -2,6 +2,7 @@ package model
22
33import (
44 "strings"
5+ "time"
56
67 tea "charm.land/bubbletea/v2"
78 "charm.land/lipgloss/v2"
@@ -11,8 +12,24 @@ import (
1112 "github.com/charmbracelet/crush/internal/ui/list"
1213 uv "github.com/charmbracelet/ultraviolet"
1314 "github.com/charmbracelet/x/ansi"
15+ "github.com/clipperhouse/displaywidth"
16+ "github.com/clipperhouse/uax29/v2/words"
1417)
1518
19+// Constants for multi-click detection.
20+const (
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.
27+type DelayedClickMsg struct {
28+ ClickID int
29+ ItemIdx int
30+ X, Y int
31+}
32+
1633// Chat represents the chat UI model that handles chat interactions and
1734// messages.
1835type Chat struct {
@@ -33,6 +50,15 @@ type Chat struct {
3350 mouseDragItem int // Current item index being dragged over
3451 mouseDragX int // Current X in item content
3552 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
3662}
3763
3864// 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) {
426452}
427453
428454// HandleMouseDown handles mouse down events for the chat component.
429−func (m *Chat) HandleMouseDown(x, y int) bool {
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.
458+func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
430459 if m.list.Len() == 0 {
431− return false
460+ return false, nil
432461 }
433462
434463 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
435464 if itemIdx < 0 {
436− return false
465+ return false, nil
437466 }
438467 if !m.isSelectable(itemIdx) {
439− return false
468+ return false, nil
440469 }
441470
442− m.mouseDown = true
443− m.mouseDownItem = itemIdx
444− m.mouseDownX = x
445− m.mouseDownY = itemY
446− m.mouseDragItem = itemIdx
447− m.mouseDragX = x
448− m.mouseDragY = itemY
471+ // Increment pending click ID to invalidate any previous pending clicks.
472+ m.pendingClickID++
473+ clickID := m.pendingClickID
474+
475+ // Detect multi-click (double/triple)
476+ now := time.Now()
477+ if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
478+ abs(x-m.lastClickX) <= clickTolerance &&
479+ abs(y-m.lastClickY) <= clickTolerance {
480+ m.clickCount++
481+ } else {
482+ m.clickCount = 1
483+ }
484+ m.lastClickTime = now
485+ m.lastClickX = x
486+ m.lastClickY = y
449487
450488 // Select the item that was clicked
451489 m.list.SetSelected(itemIdx)
452490
491+ var cmd tea.Cmd
492+
493+ switch m.clickCount {
494+ case 1:
495+ // Single click - start selection and schedule delayed click action.
496+ m.mouseDown = true
497+ m.mouseDownItem = itemIdx
498+ m.mouseDownX = x
499+ m.mouseDownY = itemY
500+ m.mouseDragItem = itemIdx
501+ m.mouseDragX = x
502+ m.mouseDragY = itemY
503+
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{
508+ ClickID: clickID,
509+ ItemIdx: itemIdx,
510+ X: x,
511+ Y: itemY,
512+ }
513+ })
514+ case 2:
515+ // Double click - select word (no delayed action)
516+ m.selectWord(itemIdx, x, itemY)
517+ case 3:
518+ // Triple click - select line (no delayed action)
519+ m.selectLine(itemIdx, itemY)
520+ m.clickCount = 0 // Reset after triple click
521+ }
522+
523+ return true, cmd
524+}
525+
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 {
532+ return false
533+ }
534+
535+ // Don't expand if user dragged to select text.
536+ if m.HasHighlight() {
537+ return false
538+ }
539+
540+ // Execute the click action (e.g., expansion).
453541 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)
455543 }
456544
457− return true
545+ return false
458546}
459547
460548// HandleMouseUp handles mouse up events for the chat component.
@@ -535,6 +623,11 @@ func (m *Chat) ClearMouse() {
535623 m.mouseDown = false
536624 m.mouseDownItem = -1
537625 m.mouseDragItem = -1
626+ m.lastClickTime = time.Time{}
627+ m.lastClickX = 0
628+ m.lastClickY = 0
629+ m.clickCount = 0
630+ m.pendingClickID++ // Invalidate any pending delayed click
538631}
539632
540633// applyHighlightRange applies the current highlight range to the chat items.
@@ -612,3 +705,144 @@ func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemId
612705
613706 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
614707}
708+
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)
712+ if item == nil {
713+ return
714+ }
715+
716+ // Get the rendered content for this item
717+ var rendered string
718+ if rr, ok := item.(list.RawRenderable); ok {
719+ rendered = rr.RawRender(m.list.Width())
720+ } else {
721+ rendered = item.Render(m.list.Width())
722+ }
723+
724+ lines := strings.Split(rendered, "\n")
725+ if itemY < 0 || itemY >= len(lines) {
726+ return
727+ }
728+
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
733+ if contentX < 0 {
734+ contentX = 0
735+ }
736+
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
741+ m.mouseDown = true
742+ m.mouseDownItem = itemIdx
743+ m.mouseDownX = x
744+ m.mouseDownY = itemY
745+ m.mouseDragItem = itemIdx
746+ m.mouseDragX = x
747+ m.mouseDragY = itemY
748+ return
749+ }
750+
751+ // Set selection to the word boundaries (convert back to viewport space).
752+ // Keep mouseDown true so HandleMouseUp triggers the copy.
753+ m.mouseDown = true
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
760+}
761+
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)
765+ if item == nil {
766+ return
767+ }
768+
769+ // Get the rendered content for this item
770+ var rendered string
771+ if rr, ok := item.(list.RawRenderable); ok {
772+ rendered = rr.RawRender(m.list.Width())
773+ } else {
774+ rendered = item.Render(m.list.Width())
775+ }
776+
777+ lines := strings.Split(rendered, "\n")
778+ if itemY < 0 || itemY >= len(lines) {
779+ return
780+ }
781+
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])
786+
787+ // Set selection to the entire line.
788+ // Keep mouseDown true so HandleMouseUp triggers the copy.
789+ m.mouseDown = true
790+ m.mouseDownItem = itemIdx
791+ m.mouseDownX = 0
792+ m.mouseDownY = itemY
793+ m.mouseDragItem = itemIdx
794+ m.mouseDragX = lineLen + offset
795+ m.mouseDragY = itemY
796+}
797+
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 {
802+ return 0, 0
803+ }
804+
805+ i := displaywidth.StringGraphemes(line)
806+ for i.Next() {
807+ }
808+
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)
813+ for iter.Next() {
814+ token := iter.Value()
815+ tokenWidth := displaywidth.String(token)
816+
817+ graphemeStart := lineCol
818+ graphemeEnd := lineCol + tokenWidth
819+ lineCol += tokenWidth
820+
821+ // If clicked before this token, return the previous token boundaries.
822+ if col < graphemeStart {
823+ return lastCol, lastCol
824+ }
825+
826+ // Update lastCol to the end of this token for next iteration.
827+ lastCol = graphemeEnd
828+
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) == "" {
833+ return col, col
834+ }
835+ return graphemeStart, graphemeEnd
836+ }
837+ }
838+
839+ return col, col
840+}
841+
842+// abs returns the absolute value of an integer.
843+func abs(x int) int {
844+ if x < 0 {
845+ return -x
846+ }
847+ return x
848+}
internal/ui/model/ui.go+10−3
@@ -524,6 +524,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
524524 }
525525 case copyChatHighlightMsg:
526526 cmds = append(cmds, m.copyChatHighlight())
527+ case DelayedClickMsg:
528+ // Handle delayed single-click action (e.g., expansion).
529+ m.chat.HandleDelayedClick(msg)
527530 case tea.MouseClickMsg:
528531 // Pass mouse events to dialogs first if any are open.
529532 if m.dialog.HasDialogs() {
@@ -541,8 +544,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
541544 // Adjust for chat area position
542545 x -= m.layout.main.Min.X
543546 y -= m.layout.main.Min.Y
544− if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) && m.chat.HandleMouseDown(x, y) {
545− m.lastClickTime = time.Now()
547+ if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
548+ if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
549+ m.lastClickTime = time.Now()
550+ if cmd != nil {
551+ cmds = append(cmds, cmd)
552+ }
553+ }
546554 }
547555 }
548556
@@ -590,7 +598,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
590598 m.dialog.Update(msg)
591599 return m, tea.Batch(cmds...)
592600 }
593− const doubleClickThreshold = 500 * time.Millisecond
594601
595602 switch m.state {
596603 case uiChat: