Merge pull request #563 from charmbracelet/list-select

Kujtim Hoxha created

Text Selection

Change summary

go.mod                                            |  25 
internal/tui/components/chat/chat.go              | 186 +++++++
internal/tui/components/chat/messages/messages.go |  21 
internal/tui/components/chat/messages/tool.go     |  15 
internal/tui/exp/list/list.go                     | 403 ++++++++++++++++
internal/tui/page/chat/chat.go                    |  55 ++
internal/tui/styles/charmtone.go                  |   3 
internal/tui/styles/icons.go                      |  23 
internal/tui/styles/theme.go                      |   3 
internal/tui/tui.go                               |   2 
10 files changed, 692 insertions(+), 44 deletions(-)

Detailed changes

go.mod 🔗

@@ -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

internal/tui/components/chat/chat.go 🔗

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

internal/tui/components/chat/messages/messages.go 🔗

@@ -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

internal/tui/components/chat/messages/tool.go 🔗

@@ -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 {

internal/tui/exp/list/list.go 🔗

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

internal/tui/page/chat/chat.go 🔗

@@ -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(

internal/tui/styles/charmtone.go 🔗

@@ -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)

internal/tui/styles/icons.go 🔗

@@ -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,
+}

internal/tui/styles/theme.go 🔗

@@ -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

internal/tui/tui.go 🔗

@@ -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