Detailed changes
@@ -2,8 +2,10 @@ package chat
import (
"context"
+ "fmt"
"time"
+ "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/app"
@@ -42,6 +44,8 @@ type MessageListCmp interface {
SetSession(session.Session) tea.Cmd
GoToBottom() tea.Cmd
+ GetSelectedText() string
+ CopySelectedText() tea.Cmd
}
// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -56,6 +60,12 @@ type messageListCmp struct {
lastUserMessageTime int64
defaultListKeyMap list.KeyMap
+
+ // Click tracking for double/triple click detection
+ lastClickTime time.Time
+ lastClickX int
+ lastClickY int
+ clickCount int
}
// New creates a new message list component with custom keybindings
@@ -87,23 +97,42 @@ func (m *messageListCmp) Init() tea.Cmd {
func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseClickMsg:
+ x := msg.X - 1 // Adjust for padding
+ y := msg.Y - 1 // Adjust for padding
+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
+ return m, nil // Ignore clicks outside the component
+ }
if msg.Button == tea.MouseLeft {
- m.listCmp.StartSelection(msg.X, msg.Y-1)
+ return m, m.handleMouseClick(x, y)
}
return m, nil
case tea.MouseMotionMsg:
- if msg.Button == tea.MouseLeft {
- m.listCmp.EndSelection(msg.X, msg.Y-1)
- if msg.Y <= 1 {
+ x := msg.X - 1 // Adjust for padding
+ y := msg.Y - 1 // Adjust for padding
+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
+ if y < 0 {
return m, m.listCmp.MoveUp(1)
- } else if msg.Y >= m.height-1 {
+ }
+ if y >= m.height-1 {
return m, m.listCmp.MoveDown(1)
}
+ return m, nil // Ignore clicks outside the component
+ }
+ if msg.Button == tea.MouseLeft {
+ m.listCmp.EndSelection(x, y)
}
return m, nil
case tea.MouseReleaseMsg:
+ x := msg.X - 1 // Adjust for padding
+ y := msg.Y - 1 // Adjust for padding
if msg.Button == tea.MouseLeft {
- m.listCmp.EndSelection(msg.X, msg.Y-1)
+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
+ m.listCmp.SelectionStop()
+ return m, m.CopySelectedText()
+ }
+ m.listCmp.EndSelection(x, y)
+ m.listCmp.SelectionStop()
+ return m, m.CopySelectedText()
}
return m, nil
case pubsub.Event[permission.PermissionNotification]:
@@ -586,3 +615,69 @@ func (m *messageListCmp) Bindings() []key.Binding {
func (m *messageListCmp) GoToBottom() tea.Cmd {
return m.listCmp.GoToBottom()
}
+
+// handleMouseClick handles mouse click events and detects double/triple clicks.
+func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
+ const (
+ doubleClickThreshold = 500 * time.Millisecond
+ clickTolerance = 2 // pixels
+ )
+
+ now := time.Now()
+
+ // Check if this is a potential multi-click
+ 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
+
+ switch m.clickCount {
+ case 1:
+ // Single click - start selection
+ m.listCmp.StartSelection(x, y)
+ case 2:
+ // Double click - select word
+ m.listCmp.SelectWord(x, y)
+ case 3:
+ // Triple click - select paragraph
+ m.listCmp.SelectParagraph(x, y)
+ m.clickCount = 0 // Reset after triple click
+ }
+
+ return nil
+}
+
+// GetSelectedText returns the currently selected text from the list component.
+func (m *messageListCmp) GetSelectedText() string {
+ return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
+}
+
+// CopySelectedText copies the currently selected text to the clipboard.
+func (m *messageListCmp) CopySelectedText() tea.Cmd {
+ return nil
+ selectedText := m.GetSelectedText()
+ if selectedText == "" {
+ return util.ReportInfo("No text selected")
+ }
+
+ err := clipboard.WriteAll(selectedText)
+ if err != nil {
+ return util.ReportError(fmt.Errorf("failed to copy selected text to clipboard: %w", err))
+ }
+ return util.ReportInfo("Selected text copied to clipboard")
+}
+
+// abs returns the absolute value of an integer.
+func abs(x int) int {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
@@ -1,7 +1,6 @@
package list
import (
- "log/slog"
"slices"
"strings"
"sync"
@@ -15,6 +14,8 @@ import (
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/rivo/uniseg"
)
type Item interface {
@@ -50,6 +51,11 @@ type List[T Item] interface {
AppendItem(T) tea.Cmd
StartSelection(col, line int)
EndSelection(col, line int)
+ SelectionStop()
+ SelectionClear()
+ SelectWord(col, line int)
+ SelectParagraph(col, line int)
+ GetSelectedText(paddingLeft int) string
}
type direction int
@@ -103,6 +109,8 @@ type list[T Item] struct {
selectionStartLine int
selectionEndCol int
selectionEndLine int
+
+ selectionActive bool
}
type ListOption func(*confOptions)
@@ -298,7 +306,8 @@ func (l *list[T]) View() string {
Height(l.height).
Width(l.width).
Render(strings.Join(lines, "\n"))
- if l.selectionStartCol < 0 {
+
+ if !l.hasSelection() {
return view
}
area := uv.Rect(0, 0, l.width, l.height)
@@ -311,8 +320,8 @@ func (l *list[T]) View() string {
}
selArea = selArea.Canon()
- specialChars := make(map[string]bool, len(styles.AllIcons))
- for _, icon := range styles.AllIcons {
+ specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
+ for _, icon := range styles.SelectionIgnoreIcons {
specialChars[icon] = true
}
@@ -951,21 +960,67 @@ func (l *list[T]) decrementOffset(n int) {
// MoveDown implements List.
func (l *list[T]) MoveDown(n int) tea.Cmd {
+ oldOffset := l.offset
if l.direction == DirectionForward {
l.incrementOffset(n)
} else {
l.decrementOffset(n)
}
+
+ if oldOffset == l.offset {
+ // no change in offset, so no need to change selection
+ return nil
+ }
+ // if we are not actively selecting move the whole selection down
+ if l.hasSelection() && !l.selectionActive {
+ if l.selectionStartLine < l.selectionEndLine {
+ l.selectionStartLine -= n
+ l.selectionEndLine -= n
+ } else {
+ l.selectionStartLine -= n
+ l.selectionEndLine -= n
+ }
+ }
+ if l.selectionActive {
+ if l.selectionStartLine < l.selectionEndLine {
+ l.selectionStartLine -= n
+ } else {
+ l.selectionEndLine -= n
+ }
+ }
return l.changeSelectionWhenScrolling()
}
// MoveUp implements List.
func (l *list[T]) MoveUp(n int) tea.Cmd {
+ oldOffset := l.offset
if l.direction == DirectionForward {
l.decrementOffset(n)
} else {
l.incrementOffset(n)
}
+
+ if oldOffset == l.offset {
+ // no change in offset, so no need to change selection
+ return nil
+ }
+
+ if l.hasSelection() && !l.selectionActive {
+ if l.selectionStartLine > l.selectionEndLine {
+ l.selectionStartLine += n
+ l.selectionEndLine += n
+ } else {
+ l.selectionStartLine += n
+ l.selectionEndLine += n
+ }
+ }
+ if l.selectionActive {
+ if l.selectionStartLine > l.selectionEndLine {
+ l.selectionStartLine += n
+ } else {
+ l.selectionEndLine += n
+ }
+ }
return l.changeSelectionWhenScrolling()
}
@@ -1164,18 +1219,224 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
return tea.Sequence(cmds...)
}
+func (l *list[T]) hasSelection() bool {
+ return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
+}
+
// StartSelection implements List.
func (l *list[T]) StartSelection(col, line int) {
l.selectionStartCol = col
l.selectionStartLine = line
l.selectionEndCol = col
l.selectionEndLine = line
- slog.Info("Position", "col", col, "line", line)
+ l.selectionActive = true
}
// EndSelection implements List.
func (l *list[T]) EndSelection(col, line int) {
+ if !l.selectionActive {
+ return
+ }
l.selectionEndCol = col
l.selectionEndLine = line
- slog.Info("Position", "col", col, "line", line)
+}
+
+func (l *list[T]) SelectionStop() {
+ l.selectionActive = false
+}
+
+func (l *list[T]) SelectionClear() {
+ l.selectionStartCol = -1
+ l.selectionStartLine = -1
+ l.selectionEndCol = -1
+ l.selectionEndLine = -1
+ l.selectionActive = false
+}
+
+func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
+ lines := strings.Split(l.rendered, "\n")
+ for i, l := range lines {
+ lines[i] = ansi.Strip(l)
+ }
+
+ if l.direction == DirectionBackward {
+ line = ((len(lines) - 1) - l.height) + line + 1
+ }
+
+ if l.offset > 0 {
+ if l.direction == DirectionBackward {
+ line -= l.offset
+ } else {
+ line += l.offset
+ }
+ }
+
+ currentLine := lines[line]
+ gr := uniseg.NewGraphemes(currentLine)
+ startCol = -1
+ upTo := col
+ for gr.Next() {
+ if gr.IsWordBoundary() && upTo > 0 {
+ startCol = col - upTo + 1
+ } else if gr.IsWordBoundary() && upTo < 0 {
+ endCol = col - upTo + 1
+ break
+ }
+ if upTo == 0 && gr.Str() == " " {
+ return 0, 0
+ }
+ upTo -= 1
+ }
+ if startCol == -1 {
+ return 0, 0
+ }
+ return
+}
+
+func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
+ lines := strings.Split(l.rendered, "\n")
+ for i, l := range lines {
+ lines[i] = ansi.Strip(l)
+ for _, icon := range styles.SelectionIgnoreIcons {
+ lines[i] = strings.ReplaceAll(lines[i], icon, " ")
+ }
+ }
+ if l.direction == DirectionBackward {
+ line = (len(lines) - 1) - l.height + line + 1
+ }
+
+ if strings.TrimSpace(lines[line]) == "" {
+ return 0, 0, false
+ }
+
+ if l.offset > 0 {
+ if l.direction == DirectionBackward {
+ line -= l.offset
+ } else {
+ line += l.offset
+ }
+ }
+
+ // Ensure line is within bounds
+ if line < 0 || line >= len(lines) {
+ return 0, 0, false
+ }
+
+ // Find start of paragraph (search backwards for empty line or start of text)
+ startLine = line
+ for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
+ startLine--
+ }
+
+ // Find end of paragraph (search forwards for empty line or end of text)
+ endLine = line
+ for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
+ endLine++
+ }
+
+ // revert the line numbers if we are in backward direction
+ if l.direction == DirectionBackward {
+ startLine = startLine - (len(lines) - 1) + l.height - 1
+ endLine = endLine - (len(lines) - 1) + l.height - 1
+ }
+ if l.offset > 0 {
+ if l.direction == DirectionBackward {
+ startLine += l.offset
+ endLine += l.offset
+ } else {
+ startLine -= l.offset
+ endLine -= l.offset
+ }
+ }
+ return startLine, endLine, true
+}
+
+// SelectWord selects the word at the given position.
+func (l *list[T]) SelectWord(col, line int) {
+ startCol, endCol := l.findWordBoundaries(col, line)
+ l.selectionStartCol = startCol
+ l.selectionStartLine = line
+ l.selectionEndCol = endCol
+ l.selectionEndLine = line
+ l.selectionActive = false // Not actively selecting, just selected
+}
+
+// SelectParagraph selects the paragraph at the given position.
+func (l *list[T]) SelectParagraph(col, line int) {
+ startLine, endLine, found := l.findParagraphBoundaries(line)
+ if !found {
+ return
+ }
+ l.selectionStartCol = 0
+ l.selectionStartLine = startLine
+ l.selectionEndCol = l.width - 1
+ l.selectionEndLine = endLine
+ l.selectionActive = false // Not actively selecting, just selected
+}
+
+// GetSelectedText returns the currently selected text.
+func (l *list[T]) GetSelectedText(paddingLeft int) string {
+ return ""
+ // if !l.hasSelection() {
+ // return ""
+ // }
+ //
+ // startLine := l.selectionStartLine
+ // endLine := l.selectionEndLine
+ // startCol := l.selectionStartCol
+ // endCol := l.selectionEndCol
+ //
+ // if l.direction == DirectionBackward {
+ // startLine = (lipgloss.Height(l.rendered) - 1) - startLine
+ // endLine = (lipgloss.Height(l.rendered) - 1) - endLine
+ // }
+ //
+ // if l.offset > 0 {
+ // if l.direction == DirectionBackward {
+ // startLine += l.offset
+ // endLine += l.offset
+ // } else {
+ // startLine -= l.offset
+ // endLine -= l.offset
+ // }
+ // }
+ //
+ // lines := strings.Split(l.rendered, "\n")
+ //
+ // if startLine < 0 || endLine < 0 || startLine >= len(lines) || endLine >= len(lines) {
+ // return ""
+ // }
+ //
+ // var result strings.Builder
+ // for i := range lines {
+ // lines[i] = ansi.Strip(lines[i])
+ // for _, icon := range styles.SelectionIgnoreIcons {
+ // lines[i] = strings.ReplaceAll(lines[i], icon, " ")
+ // }
+ //
+ // if i == startLine {
+ // if startCol < 0 || startCol >= len(lines[i]) {
+ // startCol = 0
+ // }
+ // if startCol < paddingLeft {
+ // startCol = paddingLeft
+ // }
+ // if i != endLine {
+ // endCol = len(lines[i])
+ // }
+ // result.WriteString(strings.TrimRightFunc(lines[i][startCol:endCol], unicode.IsSpace))
+ // } else if i > startLine && i < endLine {
+ // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:], unicode.IsSpace))
+ // } else if i == endLine {
+ // if endCol < 0 || endCol >= len(lines[i]) {
+ // endCol = len(lines[i])
+ // }
+ // if endCol < paddingLeft {
+ // endCol = paddingLeft
+ // }
+ // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:endCol], unicode.IsSpace))
+ // }
+ // }
+ //
+ // return result.String()
}
@@ -172,30 +172,22 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return p, nil
case tea.MouseClickMsg:
- if msg.Button == tea.MouseLeft {
- if p.isMouseOverChat(msg.X, msg.Y) {
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- }
- }
+ u, cmd := p.chat.Update(msg)
+ p.chat = u.(chat.MessageListCmp)
+ return p, cmd
return p, nil
case tea.MouseMotionMsg:
if msg.Button == tea.MouseLeft {
- if p.isMouseOverChat(msg.X, msg.Y) {
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- }
+ u, cmd := p.chat.Update(msg)
+ p.chat = u.(chat.MessageListCmp)
+ return p, cmd
}
return p, nil
case tea.MouseReleaseMsg:
if msg.Button == tea.MouseLeft {
- if p.isMouseOverChat(msg.X, msg.Y) {
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- }
+ u, cmd := p.chat.Update(msg)
+ p.chat = u.(chat.MessageListCmp)
+ return p, cmd
}
return p, nil
case tea.WindowSizeMsg:
@@ -16,24 +16,26 @@ const (
ToolSuccess string = "✓"
ToolError string = "×"
- BorderThin string = "│"
+ BorderThin string = "│"
+ BorderThick string = "▌"
)
-var AllIcons = []string{
- CheckIcon,
- ErrorIcon,
- WarningIcon,
- InfoIcon,
- HintIcon,
- SpinnerIcon,
- LoadingIcon,
- DocumentIcon,
- ModelIcon,
-
- // Tool call icons
- ToolPending,
- ToolSuccess,
- ToolError,
+var SelectionIgnoreIcons = []string{
+ // CheckIcon,
+ // ErrorIcon,
+ // WarningIcon,
+ // InfoIcon,
+ // HintIcon,
+ // SpinnerIcon,
+ // LoadingIcon,
+ // DocumentIcon,
+ // ModelIcon,
+ //
+ // // Tool call icons
+ // ToolPending,
+ // ToolSuccess,
+ // ToolError,
BorderThin,
+ BorderThick,
}
@@ -41,7 +41,7 @@ func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
case tea.MouseWheelMsg, tea.MouseMotionMsg:
now := time.Now()
// trackpad is sending too many requests
- if now.Sub(lastMouseEvent) < 20*time.Millisecond {
+ if now.Sub(lastMouseEvent) < 15*time.Millisecond {
return nil
}
lastMouseEvent = now