on main help sign in
Merge pull request #563 from charmbracelet/list-select

Text Selection

commit6984dad41ef2724cdff97e25a6183669eef53c1d authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 7 Aug 2025, 20:23 (1 year ago) parent7b95fc58566940
10 files changed+692−44
go.mod+11−14 internal/tui/components/chat/chat.go+180−6 internal/tui/components/chat/messages/messages.go+14−7 internal/tui/components/chat/messages/tool.go+9−6 internal/tui/exp/list/list.go+398−5 internal/tui/page/chat/chat.go+50−5 internal/tui/styles/charmtone.go+3 internal/tui/styles/icons.go+23 internal/tui/styles/theme.go+3 internal/tui/tui.go+1−1
go.mod+11−14
@@ -48,22 +48,10 @@ require (
4848 mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8
4949)
5050
51−require (
52− cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
53− github.com/bahlo/generic-list-go v0.2.0 // indirect
54− github.com/buger/jsonparser v1.1.1 // indirect
55− github.com/mailru/easyjson v0.7.7 // indirect
56− github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
57− go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
58− golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
59− golang.org/x/oauth2 v0.30.0 // indirect
60− golang.org/x/time v0.8.0 // indirect
61− google.golang.org/api v0.211.0 // indirect
62−)
63−
6451require (
6552 cloud.google.com/go v0.116.0 // indirect
6653 cloud.google.com/go/auth v0.13.0 // indirect
54+ cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
6755 cloud.google.com/go/compute/metadata v0.6.0 // indirect
6856 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
6957 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
@@ -84,8 +72,10 @@ require (
8472 github.com/aws/smithy-go v1.20.3 // indirect
8573 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
8674 github.com/aymerick/douceur v0.2.0 // indirect
75+ github.com/bahlo/generic-list-go v0.2.0 // indirect
76+ github.com/buger/jsonparser v1.1.1 // indirect
8777 github.com/charmbracelet/colorprofile v0.3.1 // indirect
88− github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d // indirect
78+ github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d
8979 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
9080 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
9181 github.com/charmbracelet/x/term v0.2.1
@@ -108,6 +98,7 @@ require (
10898 github.com/inconshreveable/mousetrap v1.1.0 // indirect
10999 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
110100 github.com/lucasb-eyer/go-colorful v1.2.0
101+ github.com/mailru/easyjson v0.7.7 // indirect
111102 github.com/mattn/go-isatty v0.0.20 // indirect
112103 github.com/mattn/go-runewidth v0.0.16 // indirect
113104 github.com/mfridman/interpolate v0.0.2 // indirect
@@ -128,23 +119,29 @@ require (
128119 github.com/tidwall/gjson v1.18.0 // indirect
129120 github.com/tidwall/match v1.1.1 // indirect
130121 github.com/tidwall/pretty v1.2.1 // indirect
122+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
131123 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
132124 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
133125 github.com/yuin/goldmark v1.7.8 // indirect
134126 github.com/yuin/goldmark-emoji v1.0.5 // indirect
135127 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
128+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
136129 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
137130 go.opentelemetry.io/otel v1.35.0 // indirect
138131 go.opentelemetry.io/otel/metric v1.35.0 // indirect
139132 go.opentelemetry.io/otel/trace v1.35.0 // indirect
140133 go.uber.org/multierr v1.11.0 // indirect
141134 golang.org/x/crypto v0.38.0 // indirect
135+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
142136 golang.org/x/image v0.26.0 // indirect
143137 golang.org/x/net v0.40.0 // indirect
138+ golang.org/x/oauth2 v0.30.0 // indirect
144139 golang.org/x/sync v0.16.0 // indirect
145140 golang.org/x/sys v0.34.0
146141 golang.org/x/term v0.32.0 // indirect
147142 golang.org/x/text v0.27.0
143+ golang.org/x/time v0.8.0 // indirect
144+ google.golang.org/api v0.211.0 // indirect
148145 google.golang.org/genai v1.3.0
149146 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
150147 google.golang.org/grpc v1.71.0 // indirect
internal/tui/components/chat/chat.go+180−6
@@ -4,6 +4,7 @@ import (
44 "context"
55 "time"
66
7+ "github.com/atotto/clipboard"
78 "github.com/charmbracelet/bubbles/v2/key"
89 tea "github.com/charmbracelet/bubbletea/v2"
910 "github.com/charmbracelet/crush/internal/app"
@@ -28,6 +29,12 @@ type SessionSelectedMsg = session.Session
2829
2930type SessionClearedMsg struct{}
3031
32+type SelectionCopyMsg struct {
33+ clickCount int
34+ endSelection bool
35+ x, y int
36+}
37+
3138const (
3239 NotFound = -1
3340)
@@ -42,6 +49,8 @@ type MessageListCmp interface {
4249
4350 SetSession(session.Session) tea.Cmd
4451 GoToBottom() tea.Cmd
52+ GetSelectedText() string
53+ CopySelectedText(bool) tea.Cmd
4554}
4655
4756// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -56,6 +65,12 @@ type messageListCmp struct {
5665
5766 lastUserMessageTime int64
5867 defaultListKeyMap list.KeyMap
68+
69+ // Click tracking for double/triple click detection
70+ lastClickTime time.Time
71+ lastClickX int
72+ lastClickY int
73+ clickCount int
5974}
6075
6176// New creates a new message list component with custom keybindings
@@ -86,6 +101,73 @@ func (m *messageListCmp) Init() tea.Cmd {
86101// Update handles incoming messages and updates the component state.
87102func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88103 switch msg := msg.(type) {
104+ case tea.KeyPressMsg:
105+ if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
106+ switch {
107+ case key.Matches(msg, messages.CopyKey):
108+ return m, m.CopySelectedText(true)
109+ case key.Matches(msg, messages.ClearSelectionKey):
110+ return m, m.SelectionClear()
111+ }
112+ }
113+ case tea.MouseClickMsg:
114+ x := msg.X - 1 // Adjust for padding
115+ y := msg.Y - 1 // Adjust for padding
116+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
117+ return m, nil // Ignore clicks outside the component
118+ }
119+ if msg.Button == tea.MouseLeft {
120+ return m, m.handleMouseClick(x, y)
121+ }
122+ return m, nil
123+ case tea.MouseMotionMsg:
124+ x := msg.X - 1 // Adjust for padding
125+ y := msg.Y - 1 // Adjust for padding
126+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
127+ if y < 0 {
128+ return m, m.listCmp.MoveUp(1)
129+ }
130+ if y >= m.height-1 {
131+ return m, m.listCmp.MoveDown(1)
132+ }
133+ return m, nil // Ignore clicks outside the component
134+ }
135+ if msg.Button == tea.MouseLeft {
136+ m.listCmp.EndSelection(x, y)
137+ }
138+ return m, nil
139+ case tea.MouseReleaseMsg:
140+ x := msg.X - 1 // Adjust for padding
141+ y := msg.Y - 1 // Adjust for padding
142+ if msg.Button == tea.MouseLeft {
143+ clickCount := m.clickCount
144+ if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
145+ return m, tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
146+ return SelectionCopyMsg{
147+ clickCount: clickCount,
148+ endSelection: false,
149+ }
150+ })
151+ }
152+ return m, tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
153+ return SelectionCopyMsg{
154+ clickCount: clickCount,
155+ endSelection: true,
156+ x: x,
157+ y: y,
158+ }
159+ })
160+ }
161+ return m, nil
162+ case SelectionCopyMsg:
163+ if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
164+ // If the click count matches and within threshold, copy selected text
165+ if msg.endSelection {
166+ m.listCmp.EndSelection(msg.x, msg.y)
167+ }
168+ m.listCmp.SelectionStop()
169+ return m, m.CopySelectedText(true)
170+ }
89171 case pubsub.Event[permission.PermissionNotification]:
90172 return m, m.handlePermissionRequest(msg.Payload)
91173 case SessionSelectedMsg:
@@ -106,13 +188,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106188 u, cmd := m.listCmp.Update(msg)
107189 m.listCmp = u.(list.List[list.Item])
108190 return m, cmd
109− default:
110− var cmds []tea.Cmd
111− u, cmd := m.listCmp.Update(msg)
112− m.listCmp = u.(list.List[list.Item])
113− cmds = append(cmds, cmd)
114− return m, tea.Batch(cmds...)
115191 }
192+
193+ u, cmd := m.listCmp.Update(msg)
194+ m.listCmp = u.(list.List[list.Item])
195+ return m, cmd
116196}
117197
118198// View renders the message list or an initial screen if empty.
@@ -566,3 +646,97 @@ func (m *messageListCmp) Bindings() []key.Binding {
566646func (m *messageListCmp) GoToBottom() tea.Cmd {
567647 return m.listCmp.GoToBottom()
568648}
649+
650+const (
651+ doubleClickThreshold = 500 * time.Millisecond
652+ clickTolerance = 2 // pixels
653+)
654+
655+// handleMouseClick handles mouse click events and detects double/triple clicks.
656+func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
657+ now := time.Now()
658+
659+ // Check if this is a potential multi-click
660+ if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
661+ abs(x-m.lastClickX) <= clickTolerance &&
662+ abs(y-m.lastClickY) <= clickTolerance {
663+ m.clickCount++
664+ } else {
665+ m.clickCount = 1
666+ }
667+
668+ m.lastClickTime = now
669+ m.lastClickX = x
670+ m.lastClickY = y
671+
672+ switch m.clickCount {
673+ case 1:
674+ // Single click - start selection
675+ m.listCmp.StartSelection(x, y)
676+ case 2:
677+ // Double click - select word
678+ m.listCmp.SelectWord(x, y)
679+ case 3:
680+ // Triple click - select paragraph
681+ m.listCmp.SelectParagraph(x, y)
682+ m.clickCount = 0 // Reset after triple click
683+ }
684+
685+ return nil
686+}
687+
688+// SelectionClear clears the current selection in the list component.
689+func (m *messageListCmp) SelectionClear() tea.Cmd {
690+ m.listCmp.SelectionClear()
691+ m.previousSelected = ""
692+ m.lastClickX, m.lastClickY = 0, 0
693+ m.lastClickTime = time.Time{}
694+ m.clickCount = 0
695+ return nil
696+}
697+
698+// HasSelection checks if there is a selection in the list component.
699+func (m *messageListCmp) HasSelection() bool {
700+ return m.listCmp.HasSelection()
701+}
702+
703+// GetSelectedText returns the currently selected text from the list component.
704+func (m *messageListCmp) GetSelectedText() string {
705+ return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
706+}
707+
708+// CopySelectedText copies the currently selected text to the clipboard. When
709+// clear is true, it clears the selection after copying.
710+func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
711+ if !m.listCmp.HasSelection() {
712+ return nil
713+ }
714+
715+ selectedText := m.GetSelectedText()
716+ if selectedText == "" {
717+ return util.ReportInfo("No text selected")
718+ }
719+
720+ if clear {
721+ defer func() { m.SelectionClear() }()
722+ }
723+
724+ return tea.Sequence(
725+ // We use both OSC 52 and native clipboard for compatibility with different
726+ // terminal emulators and environments.
727+ tea.SetClipboard(selectedText),
728+ func() tea.Msg {
729+ _ = clipboard.WriteAll(selectedText)
730+ return nil
731+ },
732+ util.ReportInfo("Selected text copied to clipboard"),
733+ )
734+}
735+
736+// abs returns the absolute value of an integer.
737+func abs(x int) int {
738+ if x < 0 {
739+ return -x
740+ }
741+ return x
742+}
internal/tui/components/chat/messages/messages.go+14−7
@@ -25,7 +25,11 @@ import (
2525 "github.com/charmbracelet/crush/internal/tui/util"
2626)
2727
28−var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
28+// CopyKey is the key binding for copying message content to the clipboard.
29+var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
30+
31+// ClearSelectionKey is the key binding for clearing the current selection in the chat interface.
32+var ClearSelectionKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear selection"))
2933
3034// MessageCmp defines the interface for message components in the chat interface.
3135// 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) {
99103 return m, cmd
100104 }
101105 case tea.KeyPressMsg:
102− if key.Matches(msg, copyKey) {
103− err := clipboard.WriteAll(m.message.Content().Text)
104− if err != nil {
105− return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
106− }
107− return m, util.ReportInfo("Message copied to clipboard")
106+ if key.Matches(msg, CopyKey) {
107+ return m, tea.Sequence(
108+ tea.SetClipboard(m.message.Content().Text),
109+ func() tea.Msg {
110+ _ = clipboard.WriteAll(m.message.Content().Text)
111+ return nil
112+ },
113+ util.ReportInfo("Message copied to clipboard"),
114+ )
108115 }
109116 }
110117 return m, nil
internal/tui/components/chat/messages/tool.go+9−6
@@ -165,7 +165,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165165 }
166166 return m, tea.Batch(cmds...)
167167 case tea.KeyPressMsg:
168− if key.Matches(msg, copyKey) {
168+ if key.Matches(msg, CopyKey) {
169169 return m, m.copyTool()
170170 }
171171 }
@@ -198,11 +198,14 @@ func (m *toolCallCmp) SetCancelled() {
198198
199199func (m *toolCallCmp) copyTool() tea.Cmd {
200200 content := m.formatToolForCopy()
201− err := clipboard.WriteAll(content)
202− if err != nil {
203− return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err))
204− }
205− return util.ReportInfo("Tool content copied to clipboard")
201+ return tea.Sequence(
202+ tea.SetClipboard(content),
203+ func() tea.Msg {
204+ _ = clipboard.WriteAll(content)
205+ return nil
206+ },
207+ util.ReportInfo("Tool content copied to clipboard"),
208+ )
206209}
207210
208211func (m *toolCallCmp) formatToolForCopy() string {
internal/tui/exp/list/list.go+398−5
@@ -13,6 +13,9 @@ import (
1313 "github.com/charmbracelet/crush/internal/tui/styles"
1414 "github.com/charmbracelet/crush/internal/tui/util"
1515 "github.com/charmbracelet/lipgloss/v2"
16+ uv "github.com/charmbracelet/ultraviolet"
17+ "github.com/charmbracelet/x/ansi"
18+ "github.com/rivo/uniseg"
1619)
1720
1821type Item interface {
@@ -46,6 +49,14 @@ type List[T Item] interface {
4649 DeleteItem(string) tea.Cmd
4750 PrependItem(T) tea.Cmd
4851 AppendItem(T) tea.Cmd
52+ StartSelection(col, line int)
53+ EndSelection(col, line int)
54+ SelectionStop()
55+ SelectionClear()
56+ SelectWord(col, line int)
57+ SelectParagraph(col, line int)
58+ GetSelectedText(paddingLeft int) string
59+ HasSelection() bool
4960}
5061
5162type direction int
@@ -94,7 +105,13 @@ type list[T Item] struct {
94105 renderMu sync.Mutex
95106 rendered string
96107
97− movingByItem bool
108+ movingByItem bool
109+ selectionStartCol int
110+ selectionStartLine int
111+ selectionEndCol int
112+ selectionEndLine int
113+
114+ selectionActive bool
98115}
99116
100117type ListOption func(*confOptions)
@@ -172,9 +189,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
172189 keyMap: DefaultKeyMap(),
173190 focused: true,
174191 },
175− items: csync.NewSliceFrom(items),
176− indexMap: csync.NewMap[string, int](),
177− renderedItems: csync.NewMap[string, renderedItem](),
192+ items: csync.NewSliceFrom(items),
193+ indexMap: csync.NewMap[string, int](),
194+ renderedItems: csync.NewMap[string, renderedItem](),
195+ selectionStartCol: -1,
196+ selectionStartLine: -1,
197+ selectionEndLine: -1,
198+ selectionEndCol: -1,
178199 }
179200 for _, opt := range opts {
180201 opt(list.confOptions)
@@ -266,6 +287,157 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
266287 return l, cmd
267288}
268289
290+// selectionView renders the highlighted selection in the view and returns it
291+// as a string. If textOnly is true, it won't render any styles.
292+func (l *list[T]) selectionView(view string, textOnly bool) string {
293+ t := styles.CurrentTheme()
294+ area := uv.Rect(0, 0, l.width, l.height)
295+ scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
296+ uv.NewStyledString(view).Draw(scr, area)
297+
298+ selArea := uv.Rectangle{
299+ Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
300+ Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
301+ }
302+ selArea = selArea.Canon()
303+
304+ specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
305+ for _, icon := range styles.SelectionIgnoreIcons {
306+ specialChars[icon] = true
307+ }
308+
309+ isNonWhitespace := func(r rune) bool {
310+ return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
311+ }
312+
313+ type selectionBounds struct {
314+ startX, endX int
315+ inSelection bool
316+ }
317+ lineSelections := make([]selectionBounds, scr.Height())
318+
319+ for y := range scr.Height() {
320+ bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
321+
322+ if y >= selArea.Min.Y && y <= selArea.Max.Y {
323+ bounds.inSelection = true
324+ if selArea.Min.Y == selArea.Max.Y {
325+ // Single line selection
326+ bounds.startX = selArea.Min.X
327+ bounds.endX = selArea.Max.X
328+ } else if y == selArea.Min.Y {
329+ // First line of multi-line selection
330+ bounds.startX = selArea.Min.X
331+ bounds.endX = scr.Width()
332+ } else if y == selArea.Max.Y {
333+ // Last line of multi-line selection
334+ bounds.startX = 0
335+ bounds.endX = selArea.Max.X
336+ } else {
337+ // Middle lines
338+ bounds.startX = 0
339+ bounds.endX = scr.Width()
340+ }
341+ }
342+ lineSelections[y] = bounds
343+ }
344+
345+ type lineBounds struct {
346+ start, end int
347+ }
348+ lineTextBounds := make([]lineBounds, scr.Height())
349+
350+ // First pass: find text bounds for lines that have selections
351+ for y := range scr.Height() {
352+ bounds := lineBounds{start: -1, end: -1}
353+
354+ // Only process lines that might have selections
355+ if lineSelections[y].inSelection {
356+ for x := range scr.Width() {
357+ cell := scr.CellAt(x, y)
358+ if cell == nil {
359+ continue
360+ }
361+
362+ cellStr := cell.String()
363+ if len(cellStr) == 0 {
364+ continue
365+ }
366+
367+ char := rune(cellStr[0])
368+ isSpecial := specialChars[cellStr]
369+
370+ if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
371+ if bounds.start == -1 {
372+ bounds.start = x
373+ }
374+ bounds.end = x + 1 // Position after last character
375+ }
376+ }
377+ }
378+ lineTextBounds[y] = bounds
379+ }
380+
381+ var selectedText strings.Builder
382+
383+ // Second pass: apply selection highlighting
384+ for y := range scr.Height() {
385+ selBounds := lineSelections[y]
386+ if !selBounds.inSelection {
387+ continue
388+ }
389+
390+ textBounds := lineTextBounds[y]
391+ if textBounds.start < 0 {
392+ if textOnly {
393+ // We don't want to get rid of all empty lines in text-only mode
394+ selectedText.WriteByte('\n')
395+ }
396+
397+ continue // No text on this line
398+ }
399+
400+ // Only scan within the intersection of text bounds and selection bounds
401+ scanStart := max(textBounds.start, selBounds.startX)
402+ scanEnd := min(textBounds.end, selBounds.endX)
403+
404+ for x := scanStart; x < scanEnd; x++ {
405+ cell := scr.CellAt(x, y)
406+ if cell == nil {
407+ continue
408+ }
409+
410+ cellStr := cell.String()
411+ if len(cellStr) > 0 && !specialChars[cellStr] {
412+ if textOnly {
413+ // Collect selected text without styles
414+ selectedText.WriteString(cell.String())
415+ continue
416+ }
417+
418+ // Text selection styling, which is a Lip Gloss style. We must
419+ // extract the values to use in a UV style, below.
420+ ts := t.TextSelection
421+
422+ cell = cell.Clone()
423+ cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
424+ scr.SetCell(x, y, cell)
425+ }
426+ }
427+
428+ if textOnly {
429+ // Make sure we add a newline after each line of selected text
430+ selectedText.WriteByte('\n')
431+ }
432+ }
433+
434+ if textOnly {
435+ return strings.TrimSpace(selectedText.String())
436+ }
437+
438+ return scr.Render()
439+}
440+
269441// View implements List.
270442func (l *list[T]) View() string {
271443 if l.height <= 0 || l.width <= 0 {
@@ -282,10 +454,16 @@ func (l *list[T]) View() string {
282454 if l.resize {
283455 return strings.Join(lines, "\n")
284456 }
285− return t.S().Base.
457+ view = t.S().Base.
286458 Height(l.height).
287459 Width(l.width).
288460 Render(strings.Join(lines, "\n"))
461+
462+ if !l.hasSelection() {
463+ return view
464+ }
465+
466+ return l.selectionView(view, false)
289467}
290468
291469func (l *list[T]) viewPosition() (int, int) {
@@ -817,21 +995,67 @@ func (l *list[T]) decrementOffset(n int) {
817995
818996// MoveDown implements List.
819997func (l *list[T]) MoveDown(n int) tea.Cmd {
998+ oldOffset := l.offset
820999 if l.direction == DirectionForward {
8211000 l.incrementOffset(n)
8221001 } else {
8231002 l.decrementOffset(n)
8241003 }
1004+
1005+ if oldOffset == l.offset {
1006+ // no change in offset, so no need to change selection
1007+ return nil
1008+ }
1009+ // if we are not actively selecting move the whole selection down
1010+ if l.hasSelection() && !l.selectionActive {
1011+ if l.selectionStartLine < l.selectionEndLine {
1012+ l.selectionStartLine -= n
1013+ l.selectionEndLine -= n
1014+ } else {
1015+ l.selectionStartLine -= n
1016+ l.selectionEndLine -= n
1017+ }
1018+ }
1019+ if l.selectionActive {
1020+ if l.selectionStartLine < l.selectionEndLine {
1021+ l.selectionStartLine -= n
1022+ } else {
1023+ l.selectionEndLine -= n
1024+ }
1025+ }
8251026 return l.changeSelectionWhenScrolling()
8261027}
8271028
8281029// MoveUp implements List.
8291030func (l *list[T]) MoveUp(n int) tea.Cmd {
1031+ oldOffset := l.offset
8301032 if l.direction == DirectionForward {
8311033 l.decrementOffset(n)
8321034 } else {
8331035 l.incrementOffset(n)
8341036 }
1037+
1038+ if oldOffset == l.offset {
1039+ // no change in offset, so no need to change selection
1040+ return nil
1041+ }
1042+
1043+ if l.hasSelection() && !l.selectionActive {
1044+ if l.selectionStartLine > l.selectionEndLine {
1045+ l.selectionStartLine += n
1046+ l.selectionEndLine += n
1047+ } else {
1048+ l.selectionStartLine += n
1049+ l.selectionEndLine += n
1050+ }
1051+ }
1052+ if l.selectionActive {
1053+ if l.selectionStartLine > l.selectionEndLine {
1054+ l.selectionStartLine += n
1055+ } else {
1056+ l.selectionEndLine += n
1057+ }
1058+ }
8351059 return l.changeSelectionWhenScrolling()
8361060}
8371061
@@ -1029,3 +1253,172 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
10291253 }
10301254 return tea.Sequence(cmds...)
10311255}
1256+
1257+func (l *list[T]) hasSelection() bool {
1258+ return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1259+}
1260+
1261+// StartSelection implements List.
1262+func (l *list[T]) StartSelection(col, line int) {
1263+ l.selectionStartCol = col
1264+ l.selectionStartLine = line
1265+ l.selectionEndCol = col
1266+ l.selectionEndLine = line
1267+ l.selectionActive = true
1268+}
1269+
1270+// EndSelection implements List.
1271+func (l *list[T]) EndSelection(col, line int) {
1272+ if !l.selectionActive {
1273+ return
1274+ }
1275+ l.selectionEndCol = col
1276+ l.selectionEndLine = line
1277+}
1278+
1279+func (l *list[T]) SelectionStop() {
1280+ l.selectionActive = false
1281+}
1282+
1283+func (l *list[T]) SelectionClear() {
1284+ l.selectionStartCol = -1
1285+ l.selectionStartLine = -1
1286+ l.selectionEndCol = -1
1287+ l.selectionEndLine = -1
1288+ l.selectionActive = false
1289+}
1290+
1291+func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1292+ lines := strings.Split(l.rendered, "\n")
1293+ for i, l := range lines {
1294+ lines[i] = ansi.Strip(l)
1295+ }
1296+
1297+ if l.direction == DirectionBackward && len(lines) > l.height {
1298+ line = ((len(lines) - 1) - l.height) + line + 1
1299+ }
1300+
1301+ if l.offset > 0 {
1302+ if l.direction == DirectionBackward {
1303+ line -= l.offset
1304+ } else {
1305+ line += l.offset
1306+ }
1307+ }
1308+
1309+ currentLine := lines[line]
1310+ gr := uniseg.NewGraphemes(currentLine)
1311+ startCol = -1
1312+ upTo := col
1313+ for gr.Next() {
1314+ if gr.IsWordBoundary() && upTo > 0 {
1315+ startCol = col - upTo + 1
1316+ } else if gr.IsWordBoundary() && upTo < 0 {
1317+ endCol = col - upTo + 1
1318+ break
1319+ }
1320+ if upTo == 0 && gr.Str() == " " {
1321+ return 0, 0
1322+ }
1323+ upTo -= 1
1324+ }
1325+ if startCol == -1 {
1326+ return 0, 0
1327+ }
1328+ return
1329+}
1330+
1331+func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1332+ lines := strings.Split(l.rendered, "\n")
1333+ for i, l := range lines {
1334+ lines[i] = ansi.Strip(l)
1335+ for _, icon := range styles.SelectionIgnoreIcons {
1336+ lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1337+ }
1338+ }
1339+ if l.direction == DirectionBackward && len(lines) > l.height {
1340+ line = (len(lines) - 1) - l.height + line + 1
1341+ }
1342+
1343+ if strings.TrimSpace(lines[line]) == "" {
1344+ return 0, 0, false
1345+ }
1346+
1347+ if l.offset > 0 {
1348+ if l.direction == DirectionBackward {
1349+ line -= l.offset
1350+ } else {
1351+ line += l.offset
1352+ }
1353+ }
1354+
1355+ // Ensure line is within bounds
1356+ if line < 0 || line >= len(lines) {
1357+ return 0, 0, false
1358+ }
1359+
1360+ // Find start of paragraph (search backwards for empty line or start of text)
1361+ startLine = line
1362+ for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1363+ startLine--
1364+ }
1365+
1366+ // Find end of paragraph (search forwards for empty line or end of text)
1367+ endLine = line
1368+ for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1369+ endLine++
1370+ }
1371+
1372+ // revert the line numbers if we are in backward direction
1373+ if l.direction == DirectionBackward && len(lines) > l.height {
1374+ startLine = startLine - (len(lines) - 1) + l.height - 1
1375+ endLine = endLine - (len(lines) - 1) + l.height - 1
1376+ }
1377+ if l.offset > 0 {
1378+ if l.direction == DirectionBackward {
1379+ startLine += l.offset
1380+ endLine += l.offset
1381+ } else {
1382+ startLine -= l.offset
1383+ endLine -= l.offset
1384+ }
1385+ }
1386+ return startLine, endLine, true
1387+}
1388+
1389+// SelectWord selects the word at the given position.
1390+func (l *list[T]) SelectWord(col, line int) {
1391+ startCol, endCol := l.findWordBoundaries(col, line)
1392+ l.selectionStartCol = startCol
1393+ l.selectionStartLine = line
1394+ l.selectionEndCol = endCol
1395+ l.selectionEndLine = line
1396+ l.selectionActive = false // Not actively selecting, just selected
1397+}
1398+
1399+// SelectParagraph selects the paragraph at the given position.
1400+func (l *list[T]) SelectParagraph(col, line int) {
1401+ startLine, endLine, found := l.findParagraphBoundaries(line)
1402+ if !found {
1403+ return
1404+ }
1405+ l.selectionStartCol = 0
1406+ l.selectionStartLine = startLine
1407+ l.selectionEndCol = l.width - 1
1408+ l.selectionEndLine = endLine
1409+ l.selectionActive = false // Not actively selecting, just selected
1410+}
1411+
1412+// HasSelection returns whether there is an active selection.
1413+func (l *list[T]) HasSelection() bool {
1414+ return l.hasSelection()
1415+}
1416+
1417+// GetSelectedText returns the currently selected text.
1418+func (l *list[T]) GetSelectedText(paddingLeft int) string {
1419+ if !l.hasSelection() {
1420+ return ""
1421+ }
1422+
1423+ return l.selectionView(l.View(), true)
1424+}
internal/tui/page/chat/chat.go+50−5
@@ -19,6 +19,7 @@ import (
1919 "github.com/charmbracelet/crush/internal/tui/components/chat"
2020 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
2121 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
22+ "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
2223 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
2324 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
2425 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -165,12 +166,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165166 p.keyboardEnhancements = msg
166167 return p, nil
167168 case tea.MouseWheelMsg:
168− if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
169+ if p.compact {
170+ msg.Y -= 1
171+ }
172+ if p.isMouseOverChat(msg.X, msg.Y) {
173+ u, cmd := p.chat.Update(msg)
174+ p.chat = u.(chat.MessageListCmp)
175+ return p, cmd
176+ }
177+ return p, nil
178+ case tea.MouseClickMsg:
179+ if p.compact {
180+ msg.Y -= 1
181+ }
182+ if p.isMouseOverChat(msg.X, msg.Y) {
183+ p.focusedPane = PanelTypeChat
184+ p.chat.Focus()
185+ p.editor.Blur()
186+ } else {
187+ p.focusedPane = PanelTypeEditor
188+ p.editor.Focus()
189+ p.chat.Blur()
190+ }
191+ u, cmd := p.chat.Update(msg)
192+ p.chat = u.(chat.MessageListCmp)
193+ return p, cmd
194+ case tea.MouseMotionMsg:
195+ if p.compact {
196+ msg.Y -= 1
197+ }
198+ if msg.Button == tea.MouseLeft {
199+ u, cmd := p.chat.Update(msg)
200+ p.chat = u.(chat.MessageListCmp)
201+ return p, cmd
202+ }
203+ return p, nil
204+ case tea.MouseReleaseMsg:
205+ if p.compact {
206+ msg.Y -= 1
207+ }
208+ if msg.Button == tea.MouseLeft {
169209 u, cmd := p.chat.Update(msg)
170210 p.chat = u.(chat.MessageListCmp)
171211 return p, cmd
172212 }
173213 return p, nil
214+ case chat.SelectionCopyMsg:
215+ u, cmd := p.chat.Update(msg)
216+ p.chat = u.(chat.MessageListCmp)
217+ return p, cmd
174218 case tea.WindowSizeMsg:
175219 u, cmd := p.editor.Update(msg)
176220 p.editor = u.(editor.Editor)
@@ -838,10 +882,7 @@ func (p *chatPage) Help() help.KeyMap {
838882 key.WithKeys("up", "down"),
839883 key.WithHelp("↑↓", "scroll"),
840884 ),
841− key.NewBinding(
842− key.WithKeys("c", "y"),
843− key.WithHelp("c/y", "copy"),
844− ),
885+ messages.CopyKey,
845886 )
846887 fullList = append(fullList,
847888 []key.Binding{
@@ -880,6 +921,10 @@ func (p *chatPage) Help() help.KeyMap {
880921 key.WithHelp("G", "end"),
881922 ),
882923 },
924+ []key.Binding{
925+ messages.CopyKey,
926+ messages.ClearSelectionKey,
927+ },
883928 )
884929 case PanelTypeEditor:
885930 newLineBinding := key.NewBinding(
internal/tui/styles/charmtone.go+3
@@ -56,6 +56,9 @@ func NewCharmtoneTheme() *Theme {
5656 Cherry: charmtone.Cherry,
5757 }
5858
59+ // Text selection.
60+ t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
61+
5962 // LSP and MCP status.
6063 t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
6164 t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
internal/tui/styles/icons.go+23
@@ -15,4 +15,27 @@ const (
1515 ToolPending string = "●"
1616 ToolSuccess string = "✓"
1717 ToolError string = "×"
18+
19+ BorderThin string = "│"
20+ BorderThick string = "▌"
1821)
22+
23+var SelectionIgnoreIcons = []string{
24+ // CheckIcon,
25+ // ErrorIcon,
26+ // WarningIcon,
27+ // InfoIcon,
28+ // HintIcon,
29+ // SpinnerIcon,
30+ // LoadingIcon,
31+ // DocumentIcon,
32+ // ModelIcon,
33+ //
34+ // // Tool call icons
35+ // ToolPending,
36+ // ToolSuccess,
37+ // ToolError,
38+
39+ BorderThin,
40+ BorderThick,
41+}
internal/tui/styles/theme.go+3
@@ -74,6 +74,9 @@ type Theme struct {
7474 RedLight color.Color
7575 Cherry color.Color
7676
77+ // Text selection.
78+ TextSelection lipgloss.Style
79+
7780 // LSP and MCP status indicators.
7881 ItemOfflineIcon lipgloss.Style
7982 ItemBusyIcon lipgloss.Style
internal/tui/tui.go+1−1
@@ -41,7 +41,7 @@ func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
4141 case tea.MouseWheelMsg, tea.MouseMotionMsg:
4242 now := time.Now()
4343 // trackpad is sending too many requests
44− if now.Sub(lastMouseEvent) < 5*time.Millisecond {
44+ if now.Sub(lastMouseEvent) < 15*time.Millisecond {
4545 return nil
4646 }
4747 lastMouseEvent = now