Detailed changes
@@ -48,22 +48,10 @@ require (
mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8
)
-require (
- cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
- github.com/bahlo/generic-list-go v0.2.0 // indirect
- github.com/buger/jsonparser v1.1.1 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
- github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
- golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
- golang.org/x/oauth2 v0.30.0 // indirect
- golang.org/x/time v0.8.0 // indirect
- google.golang.org/api v0.211.0 // indirect
-)
-
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
+ cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
@@ -84,8 +72,10 @@ require (
github.com/aws/smithy-go v1.20.3 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
- github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d
github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
github.com/charmbracelet/x/term v0.2.1
@@ -108,6 +98,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
+ github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
@@ -128,23 +119,29 @@ require (
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.40.0 // indirect
+ golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.27.0
+ golang.org/x/time v0.8.0 // indirect
+ google.golang.org/api v0.211.0 // indirect
google.golang.org/genai v1.3.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/grpc v1.71.0 // indirect
@@ -4,6 +4,7 @@ import (
"context"
"time"
+ "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/app"
@@ -28,6 +29,12 @@ type SessionSelectedMsg = session.Session
type SessionClearedMsg struct{}
+type SelectionCopyMsg struct {
+ clickCount int
+ endSelection bool
+ x, y int
+}
+
const (
NotFound = -1
)
@@ -42,6 +49,8 @@ type MessageListCmp interface {
SetSession(session.Session) tea.Cmd
GoToBottom() tea.Cmd
+ GetSelectedText() string
+ CopySelectedText(bool) tea.Cmd
}
// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -56,6 +65,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
@@ -86,6 +101,73 @@ func (m *messageListCmp) Init() tea.Cmd {
// Update handles incoming messages and updates the component state.
func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
+ switch {
+ case key.Matches(msg, messages.CopyKey):
+ return m, m.CopySelectedText(true)
+ case key.Matches(msg, messages.ClearSelectionKey):
+ return m, m.SelectionClear()
+ }
+ }
+ 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 {
+ return m, m.handleMouseClick(x, y)
+ }
+ return m, nil
+ case tea.MouseMotionMsg:
+ 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)
+ }
+ 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 {
+ clickCount := m.clickCount
+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
+ return m, tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
+ return SelectionCopyMsg{
+ clickCount: clickCount,
+ endSelection: false,
+ }
+ })
+ }
+ return m, tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
+ return SelectionCopyMsg{
+ clickCount: clickCount,
+ endSelection: true,
+ x: x,
+ y: y,
+ }
+ })
+ }
+ return m, nil
+ case SelectionCopyMsg:
+ if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
+ // If the click count matches and within threshold, copy selected text
+ if msg.endSelection {
+ m.listCmp.EndSelection(msg.x, msg.y)
+ }
+ m.listCmp.SelectionStop()
+ return m, m.CopySelectedText(true)
+ }
case pubsub.Event[permission.PermissionNotification]:
return m, m.handlePermissionRequest(msg.Payload)
case SessionSelectedMsg:
@@ -106,13 +188,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := m.listCmp.Update(msg)
m.listCmp = u.(list.List[list.Item])
return m, cmd
- default:
- var cmds []tea.Cmd
- u, cmd := m.listCmp.Update(msg)
- m.listCmp = u.(list.List[list.Item])
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
}
+
+ u, cmd := m.listCmp.Update(msg)
+ m.listCmp = u.(list.List[list.Item])
+ return m, cmd
}
// View renders the message list or an initial screen if empty.
@@ -566,3 +646,97 @@ func (m *messageListCmp) Bindings() []key.Binding {
func (m *messageListCmp) GoToBottom() tea.Cmd {
return m.listCmp.GoToBottom()
}
+
+const (
+ doubleClickThreshold = 500 * time.Millisecond
+ clickTolerance = 2 // pixels
+)
+
+// handleMouseClick handles mouse click events and detects double/triple clicks.
+func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
+ 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
+}
+
+// SelectionClear clears the current selection in the list component.
+func (m *messageListCmp) SelectionClear() tea.Cmd {
+ m.listCmp.SelectionClear()
+ m.previousSelected = ""
+ m.lastClickX, m.lastClickY = 0, 0
+ m.lastClickTime = time.Time{}
+ m.clickCount = 0
+ return nil
+}
+
+// HasSelection checks if there is a selection in the list component.
+func (m *messageListCmp) HasSelection() bool {
+ return m.listCmp.HasSelection()
+}
+
+// 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. When
+// clear is true, it clears the selection after copying.
+func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
+ if !m.listCmp.HasSelection() {
+ return nil
+ }
+
+ selectedText := m.GetSelectedText()
+ if selectedText == "" {
+ return util.ReportInfo("No text selected")
+ }
+
+ if clear {
+ defer func() { m.SelectionClear() }()
+ }
+
+ return tea.Sequence(
+ // We use both OSC 52 and native clipboard for compatibility with different
+ // terminal emulators and environments.
+ tea.SetClipboard(selectedText),
+ func() tea.Msg {
+ _ = clipboard.WriteAll(selectedText)
+ return nil
+ },
+ 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
+}
@@ -25,7 +25,11 @@ import (
"github.com/charmbracelet/crush/internal/tui/util"
)
-var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
+// CopyKey is the key binding for copying message content to the clipboard.
+var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
+
+// ClearSelectionKey is the key binding for clearing the current selection in the chat interface.
+var ClearSelectionKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear selection"))
// MessageCmp defines the interface for message components in the chat interface.
// It combines standard UI model interfaces with message-specific functionality.
@@ -99,12 +103,15 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
}
case tea.KeyPressMsg:
- if key.Matches(msg, copyKey) {
- err := clipboard.WriteAll(m.message.Content().Text)
- if err != nil {
- return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
- }
- return m, util.ReportInfo("Message copied to clipboard")
+ if key.Matches(msg, CopyKey) {
+ return m, tea.Sequence(
+ tea.SetClipboard(m.message.Content().Text),
+ func() tea.Msg {
+ _ = clipboard.WriteAll(m.message.Content().Text)
+ return nil
+ },
+ util.ReportInfo("Message copied to clipboard"),
+ )
}
}
return m, nil
@@ -165,7 +165,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Batch(cmds...)
case tea.KeyPressMsg:
- if key.Matches(msg, copyKey) {
+ if key.Matches(msg, CopyKey) {
return m, m.copyTool()
}
}
@@ -198,11 +198,14 @@ func (m *toolCallCmp) SetCancelled() {
func (m *toolCallCmp) copyTool() tea.Cmd {
content := m.formatToolForCopy()
- err := clipboard.WriteAll(content)
- if err != nil {
- return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err))
- }
- return util.ReportInfo("Tool content copied to clipboard")
+ return tea.Sequence(
+ tea.SetClipboard(content),
+ func() tea.Msg {
+ _ = clipboard.WriteAll(content)
+ return nil
+ },
+ util.ReportInfo("Tool content copied to clipboard"),
+ )
}
func (m *toolCallCmp) formatToolForCopy() string {
@@ -13,6 +13,9 @@ import (
"github.com/charmbracelet/crush/internal/tui/styles"
"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 {
@@ -46,6 +49,14 @@ type List[T Item] interface {
DeleteItem(string) tea.Cmd
PrependItem(T) tea.Cmd
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
+ HasSelection() bool
}
type direction int
@@ -94,7 +105,13 @@ type list[T Item] struct {
renderMu sync.Mutex
rendered string
- movingByItem bool
+ movingByItem bool
+ selectionStartCol int
+ selectionStartLine int
+ selectionEndCol int
+ selectionEndLine int
+
+ selectionActive bool
}
type ListOption func(*confOptions)
@@ -172,9 +189,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
keyMap: DefaultKeyMap(),
focused: true,
},
- items: csync.NewSliceFrom(items),
- indexMap: csync.NewMap[string, int](),
- renderedItems: csync.NewMap[string, renderedItem](),
+ items: csync.NewSliceFrom(items),
+ indexMap: csync.NewMap[string, int](),
+ renderedItems: csync.NewMap[string, renderedItem](),
+ selectionStartCol: -1,
+ selectionStartLine: -1,
+ selectionEndLine: -1,
+ selectionEndCol: -1,
}
for _, opt := range opts {
opt(list.confOptions)
@@ -266,6 +287,157 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
return l, cmd
}
+// selectionView renders the highlighted selection in the view and returns it
+// as a string. If textOnly is true, it won't render any styles.
+func (l *list[T]) selectionView(view string, textOnly bool) string {
+ t := styles.CurrentTheme()
+ area := uv.Rect(0, 0, l.width, l.height)
+ scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
+ uv.NewStyledString(view).Draw(scr, area)
+
+ selArea := uv.Rectangle{
+ Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
+ Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
+ }
+ selArea = selArea.Canon()
+
+ specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
+ for _, icon := range styles.SelectionIgnoreIcons {
+ specialChars[icon] = true
+ }
+
+ isNonWhitespace := func(r rune) bool {
+ return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
+ }
+
+ type selectionBounds struct {
+ startX, endX int
+ inSelection bool
+ }
+ lineSelections := make([]selectionBounds, scr.Height())
+
+ for y := range scr.Height() {
+ bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
+
+ if y >= selArea.Min.Y && y <= selArea.Max.Y {
+ bounds.inSelection = true
+ if selArea.Min.Y == selArea.Max.Y {
+ // Single line selection
+ bounds.startX = selArea.Min.X
+ bounds.endX = selArea.Max.X
+ } else if y == selArea.Min.Y {
+ // First line of multi-line selection
+ bounds.startX = selArea.Min.X
+ bounds.endX = scr.Width()
+ } else if y == selArea.Max.Y {
+ // Last line of multi-line selection
+ bounds.startX = 0
+ bounds.endX = selArea.Max.X
+ } else {
+ // Middle lines
+ bounds.startX = 0
+ bounds.endX = scr.Width()
+ }
+ }
+ lineSelections[y] = bounds
+ }
+
+ type lineBounds struct {
+ start, end int
+ }
+ lineTextBounds := make([]lineBounds, scr.Height())
+
+ // First pass: find text bounds for lines that have selections
+ for y := range scr.Height() {
+ bounds := lineBounds{start: -1, end: -1}
+
+ // Only process lines that might have selections
+ if lineSelections[y].inSelection {
+ for x := range scr.Width() {
+ cell := scr.CellAt(x, y)
+ if cell == nil {
+ continue
+ }
+
+ cellStr := cell.String()
+ if len(cellStr) == 0 {
+ continue
+ }
+
+ char := rune(cellStr[0])
+ isSpecial := specialChars[cellStr]
+
+ if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
+ if bounds.start == -1 {
+ bounds.start = x
+ }
+ bounds.end = x + 1 // Position after last character
+ }
+ }
+ }
+ lineTextBounds[y] = bounds
+ }
+
+ var selectedText strings.Builder
+
+ // Second pass: apply selection highlighting
+ for y := range scr.Height() {
+ selBounds := lineSelections[y]
+ if !selBounds.inSelection {
+ continue
+ }
+
+ textBounds := lineTextBounds[y]
+ if textBounds.start < 0 {
+ if textOnly {
+ // We don't want to get rid of all empty lines in text-only mode
+ selectedText.WriteByte('\n')
+ }
+
+ continue // No text on this line
+ }
+
+ // Only scan within the intersection of text bounds and selection bounds
+ scanStart := max(textBounds.start, selBounds.startX)
+ scanEnd := min(textBounds.end, selBounds.endX)
+
+ for x := scanStart; x < scanEnd; x++ {
+ cell := scr.CellAt(x, y)
+ if cell == nil {
+ continue
+ }
+
+ cellStr := cell.String()
+ if len(cellStr) > 0 && !specialChars[cellStr] {
+ if textOnly {
+ // Collect selected text without styles
+ selectedText.WriteString(cell.String())
+ continue
+ }
+
+ // Text selection styling, which is a Lip Gloss style. We must
+ // extract the values to use in a UV style, below.
+ ts := t.TextSelection
+
+ cell = cell.Clone()
+ cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
+ scr.SetCell(x, y, cell)
+ }
+ }
+
+ if textOnly {
+ // Make sure we add a newline after each line of selected text
+ selectedText.WriteByte('\n')
+ }
+ }
+
+ if textOnly {
+ return strings.TrimSpace(selectedText.String())
+ }
+
+ return scr.Render()
+}
+
// View implements List.
func (l *list[T]) View() string {
if l.height <= 0 || l.width <= 0 {
@@ -282,10 +454,16 @@ func (l *list[T]) View() string {
if l.resize {
return strings.Join(lines, "\n")
}
- return t.S().Base.
+ view = t.S().Base.
Height(l.height).
Width(l.width).
Render(strings.Join(lines, "\n"))
+
+ if !l.hasSelection() {
+ return view
+ }
+
+ return l.selectionView(view, false)
}
func (l *list[T]) viewPosition() (int, int) {
@@ -817,21 +995,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()
}
@@ -1029,3 +1253,172 @@ 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
+ l.selectionActive = true
+}
+
+// EndSelection implements List.
+func (l *list[T]) EndSelection(col, line int) {
+ if !l.selectionActive {
+ return
+ }
+ l.selectionEndCol = col
+ l.selectionEndLine = 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 && len(lines) > l.height {
+ 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 && len(lines) > l.height {
+ 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 && len(lines) > l.height {
+ 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
+}
+
+// HasSelection returns whether there is an active selection.
+func (l *list[T]) HasSelection() bool {
+ return l.hasSelection()
+}
+
+// GetSelectedText returns the currently selected text.
+func (l *list[T]) GetSelectedText(paddingLeft int) string {
+ if !l.hasSelection() {
+ return ""
+ }
+
+ return l.selectionView(l.View(), true)
+}
@@ -19,6 +19,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/chat"
"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
"github.com/charmbracelet/crush/internal/tui/components/chat/header"
+ "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
"github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -165,12 +166,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.keyboardEnhancements = msg
return p, nil
case tea.MouseWheelMsg:
- if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
+ if p.compact {
+ msg.Y -= 1
+ }
+ if p.isMouseOverChat(msg.X, msg.Y) {
+ u, cmd := p.chat.Update(msg)
+ p.chat = u.(chat.MessageListCmp)
+ return p, cmd
+ }
+ return p, nil
+ case tea.MouseClickMsg:
+ if p.compact {
+ msg.Y -= 1
+ }
+ if p.isMouseOverChat(msg.X, msg.Y) {
+ p.focusedPane = PanelTypeChat
+ p.chat.Focus()
+ p.editor.Blur()
+ } else {
+ p.focusedPane = PanelTypeEditor
+ p.editor.Focus()
+ p.chat.Blur()
+ }
+ u, cmd := p.chat.Update(msg)
+ p.chat = u.(chat.MessageListCmp)
+ return p, cmd
+ case tea.MouseMotionMsg:
+ if p.compact {
+ msg.Y -= 1
+ }
+ if msg.Button == tea.MouseLeft {
+ u, cmd := p.chat.Update(msg)
+ p.chat = u.(chat.MessageListCmp)
+ return p, cmd
+ }
+ return p, nil
+ case tea.MouseReleaseMsg:
+ if p.compact {
+ msg.Y -= 1
+ }
+ if msg.Button == tea.MouseLeft {
u, cmd := p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
return p, cmd
}
return p, nil
+ case chat.SelectionCopyMsg:
+ u, cmd := p.chat.Update(msg)
+ p.chat = u.(chat.MessageListCmp)
+ return p, cmd
case tea.WindowSizeMsg:
u, cmd := p.editor.Update(msg)
p.editor = u.(editor.Editor)
@@ -838,10 +882,7 @@ func (p *chatPage) Help() help.KeyMap {
key.WithKeys("up", "down"),
key.WithHelp("↑↓", "scroll"),
),
- key.NewBinding(
- key.WithKeys("c", "y"),
- key.WithHelp("c/y", "copy"),
- ),
+ messages.CopyKey,
)
fullList = append(fullList,
[]key.Binding{
@@ -880,6 +921,10 @@ func (p *chatPage) Help() help.KeyMap {
key.WithHelp("G", "end"),
),
},
+ []key.Binding{
+ messages.CopyKey,
+ messages.ClearSelectionKey,
+ },
)
case PanelTypeEditor:
newLineBinding := key.NewBinding(
@@ -56,6 +56,9 @@ func NewCharmtoneTheme() *Theme {
Cherry: charmtone.Cherry,
}
+ // Text selection.
+ t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
+
// LSP and MCP status.
t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
@@ -15,4 +15,27 @@ const (
ToolPending string = "●"
ToolSuccess string = "✓"
ToolError string = "×"
+
+ BorderThin string = "│"
+ BorderThick string = "▌"
)
+
+var SelectionIgnoreIcons = []string{
+ // CheckIcon,
+ // ErrorIcon,
+ // WarningIcon,
+ // InfoIcon,
+ // HintIcon,
+ // SpinnerIcon,
+ // LoadingIcon,
+ // DocumentIcon,
+ // ModelIcon,
+ //
+ // // Tool call icons
+ // ToolPending,
+ // ToolSuccess,
+ // ToolError,
+
+ BorderThin,
+ BorderThick,
+}
@@ -74,6 +74,9 @@ type Theme struct {
RedLight color.Color
Cherry color.Color
+ // Text selection.
+ TextSelection lipgloss.Style
+
// LSP and MCP status indicators.
ItemOfflineIcon lipgloss.Style
ItemBusyIcon lipgloss.Style
@@ -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) < 5*time.Millisecond {
+ if now.Sub(lastMouseEvent) < 15*time.Millisecond {
return nil
}
lastMouseEvent = now