diff --git a/go.mod b/go.mod index 4ea501fd125ce2a8ef62b4555229218b5d65ff19..2ed59fa188f17124c154c2aa60d1496be695b49c 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index af4bd1eaab7bb4696ef0d344113a435f90a7a4ac..661ba0de7ae7187dca0ed4aa690d853e7145305d 100644 --- a/go.sum +++ b/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= diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index ad8aad399cf40809f16779dd277536d9ad47d5e3..b9f16adf3ad7d5b7097e639892b1c6a6f1c22042 100644 --- a/internal/ui/chat/messages.go +++ b/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 diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 69ba5efff7bbe02c7b322ba940ecfefadf299eea..07c3d98e6f60d319df8eff3699a057ad562771b7 100644 --- a/internal/ui/chat/tools.go +++ b/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) diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 3a743edd9d1e87b643076f114b065b2eaa2b2ca5..4abe68f27aa367b5ff81ebefc89633310f93e81d 100644 --- a/internal/ui/model/chat.go +++ b/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 +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e26323f551c7099fd579c303b80f1b764a98f242..1e81a5625b909598668487b137fb80afce5754da 100644 --- a/internal/ui/model/ui.go +++ b/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: