@@ -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
@@ -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)
@@ -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
+}
@@ -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: