on main help sign in
refactor(chat): handle double click & triple click (#1959)
25f05a6Kujtim Hoxha
go.mod+2−2
@@ -33,6 +33,8 @@ require (
33 github.com/charmbracelet/x/exp/strings v0.1.0
33 github.com/charmbracelet/x/exp/strings v0.1.0
34 github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
34 github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687
35 github.com/charmbracelet/x/term v0.2.2
35 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
36 github.com/denisbrodbeck/machineid v1.0.1
38 github.com/denisbrodbeck/machineid v1.0.1
37 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
39 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
38 github.com/disintegration/imaging v1.6.2
40 github.com/disintegration/imaging v1.6.2
@@ -106,9 +108,7 @@ require (
106 github.com/charmbracelet/x/json v0.2.0 // indirect
108 github.com/charmbracelet/x/json v0.2.0 // indirect
107 github.com/charmbracelet/x/termios v0.1.1 // indirect
109 github.com/charmbracelet/x/termios v0.1.1 // indirect
108 github.com/charmbracelet/x/windows v0.2.2 // indirect
110 github.com/charmbracelet/x/windows v0.2.2 // indirect
109− github.com/clipperhouse/displaywidth v0.7.0 // indirect
110 github.com/clipperhouse/stringish v0.1.1 // indirect
111 github.com/clipperhouse/stringish v0.1.1 // indirect
111− github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
112 github.com/davecgh/go-spew v1.1.1 // indirect
112 github.com/davecgh/go-spew v1.1.1 // indirect
113 github.com/disintegration/gift v1.1.2 // indirect
113 github.com/disintegration/gift v1.1.2 // indirect
114 github.com/dlclark/regexp2 v1.11.5 // indirect
114 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
134github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
134github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
135github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
135github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
136github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
136github.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=
137+github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw=
138−github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
138+github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
139github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
139github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
140github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
140github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
141github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
141github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
internal/ui/chat/messages.go+6−6
@@ -18,9 +18,9 @@ import (
18 "github.com/charmbracelet/crush/internal/ui/styles"
18 "github.com/charmbracelet/crush/internal/ui/styles"
19)
19)
20
20
21−// this is the total width that is taken up by the border + padding
21+// MessageLeftPaddingTotal is the total width that is taken up by the border +
22−// we also cap the width so text is readable to the maxTextWidth(120)
22+// padding. We also cap the width so text is readable to the maxTextWidth(120).
23−const messageLeftPaddingTotal = 2
23+const MessageLeftPaddingTotal = 2
24
24
25// maxTextWidth is the maximum width text messages can be
25// maxTextWidth is the maximum width text messages can be
26const maxTextWidth = 120
26const maxTextWidth = 120
@@ -100,7 +100,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
100func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
100func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
101 // Adjust columns for the style's left inset (border + padding) since we
101 // Adjust columns for the style's left inset (border + padding) since we
102 // highlight the content only.
102 // highlight the content only.
103− offset := messageLeftPaddingTotal
103+ offset := MessageLeftPaddingTotal
104 h.startLine = startLine
104 h.startLine = startLine
105 h.startCol = max(0, startCol-offset)
105 h.startCol = max(0, startCol-offset)
106 h.endLine = endLine
106 h.endLine = endLine
@@ -205,7 +205,7 @@ func (a *AssistantInfoItem) ID() string {
205
205
206// RawRender implements MessageItem.
206// RawRender implements MessageItem.
207func (a *AssistantInfoItem) RawRender(width int) string {
207func (a *AssistantInfoItem) RawRender(width int) string {
208− innerWidth := max(0, width-messageLeftPaddingTotal)
208+ innerWidth := max(0, width-MessageLeftPaddingTotal)
209 content, _, ok := a.getCachedRender(innerWidth)
209 content, _, ok := a.getCachedRender(innerWidth)
210 if !ok {
210 if !ok {
211 content = a.renderContent(innerWidth)
211 content = a.renderContent(innerWidth)
@@ -245,7 +245,7 @@ func (a *AssistantInfoItem) renderContent(width int) string {
245
245
246// cappedMessageWidth returns the maximum width for message content for readability.
246// cappedMessageWidth returns the maximum width for message content for readability.
247func cappedMessageWidth(availableWidth int) int {
247func cappedMessageWidth(availableWidth int) int {
248− return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
248+ return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth)
249}
249}
250
250
251// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
251// 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 {
292
292
293// RawRender implements [MessageItem].
293// RawRender implements [MessageItem].
294func (t *baseToolMessageItem) RawRender(width int) string {
294func (t *baseToolMessageItem) RawRender(width int) string {
295− toolItemWidth := width - messageLeftPaddingTotal
295+ toolItemWidth := width - MessageLeftPaddingTotal
296 if t.hasCappedWidth {
296 if t.hasCappedWidth {
297 toolItemWidth = cappedMessageWidth(width)
297 toolItemWidth = cappedMessageWidth(width)
298 }
298 }
@@ -690,7 +690,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri
690 truncMsg := sty.Tool.DiffTruncation.
690 truncMsg := sty.Tool.DiffTruncation.
691 Width(bodyWidth).
691 Width(bodyWidth).
692 Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
692 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
694 }
694 }
695
695
696 return sty.Tool.Body.Render(formatted)
696 return sty.Tool.Body.Render(formatted)
internal/ui/model/chat.go+247−13
@@ -2,6 +2,7 @@ package model
2
2
3import (
3import (
4 "strings"
4 "strings"
5+ "time"
5
6
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"
14)
17)
15
18
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+
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
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
36}
62}
37
63
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) {
426}
452}
427
453
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
432 }
461 }
433
462
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
437 }
466 }
438 if !m.isSelectable(itemIdx) {
467 if !m.isSelectable(itemIdx) {
439− return false
468+ return false, nil
440 }
469 }
441
470
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 {
480+ m.clickCount++
481+ } else {
482+ m.clickCount = 1
483+ }
484+ m.lastClickTime = now
485+ m.lastClickX = x
486+ m.lastClickY = y
449
487
450 // Select the item that was clicked
488 // Select the item that was clicked
451 m.list.SetSelected(itemIdx)
489 m.list.SetSelected(itemIdx)
452
490
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).
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)
455 }
543 }
456
544
457− return true
545+ return false
458}
546}
459
547
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{}
627+ m.lastClickX = 0
628+ m.lastClickY = 0
629+ m.clickCount = 0
630+ m.pendingClickID++ // Invalidate any pending delayed click
538}
631}
539
632
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
612
705
613 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
706 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
614}
707}
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) {
524 }
524 }
525 case copyChatHighlightMsg:
525 case copyChatHighlightMsg:
526 cmds = append(cmds, m.copyChatHighlight())
526 cmds = append(cmds, m.copyChatHighlight())
527+ case DelayedClickMsg:
528+ // Handle delayed single-click action (e.g., expansion).
529+ m.chat.HandleDelayedClick(msg)
527 case tea.MouseClickMsg:
530 case tea.MouseClickMsg:
528 // Pass mouse events to dialogs first if any are open.
531 // Pass mouse events to dialogs first if any are open.
529 if m.dialog.HasDialogs() {
532 if m.dialog.HasDialogs() {
@@ -541,8 +544,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
541 // Adjust for chat area position
544 // Adjust for chat area position
542 x -= m.layout.main.Min.X
545 x -= m.layout.main.Min.X
543 y -= m.layout.main.Min.Y
546 y -= m.layout.main.Min.Y
544− if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) && m.chat.HandleMouseDown(x, y) {
547+ if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
548+ if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
545− m.lastClickTime = time.Now()
549+ m.lastClickTime = time.Now()
550+ if cmd != nil {
551+ cmds = append(cmds, cmd)
552+ }
553+ }
546 }
554 }
547 }
555 }
548
556
@@ -590,7 +598,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
590 m.dialog.Update(msg)
598 m.dialog.Update(msg)
591 return m, tea.Batch(cmds...)
599 return m, tea.Batch(cmds...)
592 }
600 }
593− const doubleClickThreshold = 500 * time.Millisecond
594
601
595 switch m.state {
602 switch m.state {
596 case uiChat:
603 case uiChat: