on main help sign in
Merge pull request #563 from charmbracelet/list-select
6984dadKujtim Hoxha
go.mod+11−14
@@ -48,22 +48,10 @@ require (
48 mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8
48 mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8
49)
49)
50
50
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−
64require (
51require (
65 cloud.google.com/go v0.116.0 // indirect
52 cloud.google.com/go v0.116.0 // indirect
66 cloud.google.com/go/auth v0.13.0 // indirect
53 cloud.google.com/go/auth v0.13.0 // indirect
54+ cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
67 cloud.google.com/go/compute/metadata v0.6.0 // indirect
55 cloud.google.com/go/compute/metadata v0.6.0 // indirect
68 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
56 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
69 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
57 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
@@ -84,8 +72,10 @@ require (
84 github.com/aws/smithy-go v1.20.3 // indirect
72 github.com/aws/smithy-go v1.20.3 // indirect
85 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
73 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
86 github.com/aymerick/douceur v0.2.0 // indirect
74 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
87 github.com/charmbracelet/colorprofile v0.3.1 // indirect
77 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
89 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
79 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
90 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
80 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
91 github.com/charmbracelet/x/term v0.2.1
81 github.com/charmbracelet/x/term v0.2.1
@@ -108,6 +98,7 @@ require (
108 github.com/inconshreveable/mousetrap v1.1.0 // indirect
98 github.com/inconshreveable/mousetrap v1.1.0 // indirect
109 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
99 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
110 github.com/lucasb-eyer/go-colorful v1.2.0
100 github.com/lucasb-eyer/go-colorful v1.2.0
101+ github.com/mailru/easyjson v0.7.7 // indirect
111 github.com/mattn/go-isatty v0.0.20 // indirect
102 github.com/mattn/go-isatty v0.0.20 // indirect
112 github.com/mattn/go-runewidth v0.0.16 // indirect
103 github.com/mattn/go-runewidth v0.0.16 // indirect
113 github.com/mfridman/interpolate v0.0.2 // indirect
104 github.com/mfridman/interpolate v0.0.2 // indirect
@@ -128,23 +119,29 @@ require (
128 github.com/tidwall/gjson v1.18.0 // indirect
119 github.com/tidwall/gjson v1.18.0 // indirect
129 github.com/tidwall/match v1.1.1 // indirect
120 github.com/tidwall/match v1.1.1 // indirect
130 github.com/tidwall/pretty v1.2.1 // indirect
121 github.com/tidwall/pretty v1.2.1 // indirect
122+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
131 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
123 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
132 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
124 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
133 github.com/yuin/goldmark v1.7.8 // indirect
125 github.com/yuin/goldmark v1.7.8 // indirect
134 github.com/yuin/goldmark-emoji v1.0.5 // indirect
126 github.com/yuin/goldmark-emoji v1.0.5 // indirect
135 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
127 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
128+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
136 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
129 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
137 go.opentelemetry.io/otel v1.35.0 // indirect
130 go.opentelemetry.io/otel v1.35.0 // indirect
138 go.opentelemetry.io/otel/metric v1.35.0 // indirect
131 go.opentelemetry.io/otel/metric v1.35.0 // indirect
139 go.opentelemetry.io/otel/trace v1.35.0 // indirect
132 go.opentelemetry.io/otel/trace v1.35.0 // indirect
140 go.uber.org/multierr v1.11.0 // indirect
133 go.uber.org/multierr v1.11.0 // indirect
141 golang.org/x/crypto v0.38.0 // indirect
134 golang.org/x/crypto v0.38.0 // indirect
135+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
142 golang.org/x/image v0.26.0 // indirect
136 golang.org/x/image v0.26.0 // indirect
143 golang.org/x/net v0.40.0 // indirect
137 golang.org/x/net v0.40.0 // indirect
138+ golang.org/x/oauth2 v0.30.0 // indirect
144 golang.org/x/sync v0.16.0 // indirect
139 golang.org/x/sync v0.16.0 // indirect
145 golang.org/x/sys v0.34.0
140 golang.org/x/sys v0.34.0
146 golang.org/x/term v0.32.0 // indirect
141 golang.org/x/term v0.32.0 // indirect
147 golang.org/x/text v0.27.0
142 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
148 google.golang.org/genai v1.3.0
145 google.golang.org/genai v1.3.0
149 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
146 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
150 google.golang.org/grpc v1.71.0 // indirect
147 google.golang.org/grpc v1.71.0 // indirect
internal/tui/components/chat/chat.go+180−6
@@ -4,6 +4,7 @@ import (
4 "context"
4 "context"
5 "time"
5 "time"
6
6
7+ "github.com/atotto/clipboard"
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/app"
@@ -28,6 +29,12 @@ type SessionSelectedMsg = session.Session
28
29
29type SessionClearedMsg struct{}
30type SessionClearedMsg struct{}
30
31
32+type SelectionCopyMsg struct {
33+ clickCount int
34+ endSelection bool
35+ x, y int
36+}
37+
31const (
38const (
32 NotFound = -1
39 NotFound = -1
33)
40)
@@ -42,6 +49,8 @@ type MessageListCmp interface {
42
49
43 SetSession(session.Session) tea.Cmd
50 SetSession(session.Session) tea.Cmd
44 GoToBottom() tea.Cmd
51 GoToBottom() tea.Cmd
52+ GetSelectedText() string
53+ CopySelectedText(bool) tea.Cmd
45}
54}
46
55
47// messageListCmp implements MessageListCmp, providing a virtualized list
56// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -56,6 +65,12 @@ type messageListCmp struct {
56
65
57 lastUserMessageTime int64
66 lastUserMessageTime int64
58 defaultListKeyMap list.KeyMap
67 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
59}
74}
60
75
61// New creates a new message list component with custom keybindings
76// New creates a new message list component with custom keybindings
@@ -86,6 +101,73 @@ func (m *messageListCmp) Init() tea.Cmd {
86// Update handles incoming messages and updates the component state.
101// Update handles incoming messages and updates the component state.
87func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
102func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 switch msg := msg.(type) {
103 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+ }
89 case pubsub.Event[permission.PermissionNotification]:
171 case pubsub.Event[permission.PermissionNotification]:
90 return m, m.handlePermissionRequest(msg.Payload)
172 return m, m.handlePermissionRequest(msg.Payload)
91 case SessionSelectedMsg:
173 case SessionSelectedMsg:
@@ -106,13 +188,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106 u, cmd := m.listCmp.Update(msg)
188 u, cmd := m.listCmp.Update(msg)
107 m.listCmp = u.(list.List[list.Item])
189 m.listCmp = u.(list.List[list.Item])
108 return m, cmd
190 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...)
115 }
191 }
192+
193+ u, cmd := m.listCmp.Update(msg)
194+ m.listCmp = u.(list.List[list.Item])
195+ return m, cmd
116}
196}
117
197
118// View renders the message list or an initial screen if empty.
198// View renders the message list or an initial screen if empty.
@@ -566,3 +646,97 @@ func (m *messageListCmp) Bindings() []key.Binding {
566func (m *messageListCmp) GoToBottom() tea.Cmd {
646func (m *messageListCmp) GoToBottom() tea.Cmd {
567 return m.listCmp.GoToBottom()
647 return m.listCmp.GoToBottom()
568}
648}
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 (
25 "github.com/charmbracelet/crush/internal/tui/util"
25 "github.com/charmbracelet/crush/internal/tui/util"
26)
26)
27
27
28+// CopyKey is the key binding for copying message content to the clipboard.
28−var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
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"))
29
33
30// MessageCmp defines the interface for message components in the chat interface.
34// MessageCmp defines the interface for message components in the chat interface.
31// It combines standard UI model interfaces with message-specific functionality.
35// 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) {
99 return m, cmd
103 return m, cmd
100 }
104 }
101 case tea.KeyPressMsg:
105 case tea.KeyPressMsg:
102− if key.Matches(msg, copyKey) {
106+ if key.Matches(msg, CopyKey) {
107+ return m, tea.Sequence(
108+ tea.SetClipboard(m.message.Content().Text),
109+ func() tea.Msg {
103− err := clipboard.WriteAll(m.message.Content().Text)
110+ _ = clipboard.WriteAll(m.message.Content().Text)
104− if err != nil {
111+ return nil
105− return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
106− }
112+ },
107− return m, util.ReportInfo("Message copied to clipboard")
113+ util.ReportInfo("Message copied to clipboard"),
114+ )
108 }
115 }
109 }
116 }
110 return m, nil
117 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) {
165 }
165 }
166 return m, tea.Batch(cmds...)
166 return m, tea.Batch(cmds...)
167 case tea.KeyPressMsg:
167 case tea.KeyPressMsg:
168− if key.Matches(msg, copyKey) {
168+ if key.Matches(msg, CopyKey) {
169 return m, m.copyTool()
169 return m, m.copyTool()
170 }
170 }
171 }
171 }
@@ -198,11 +198,14 @@ func (m *toolCallCmp) SetCancelled() {
198
198
199func (m *toolCallCmp) copyTool() tea.Cmd {
199func (m *toolCallCmp) copyTool() tea.Cmd {
200 content := m.formatToolForCopy()
200 content := m.formatToolForCopy()
201+ return tea.Sequence(
202+ tea.SetClipboard(content),
203+ func() tea.Msg {
201− err := clipboard.WriteAll(content)
204+ _ = clipboard.WriteAll(content)
202− if err != nil {
205+ return nil
203− return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err))
204− }
206+ },
205− return util.ReportInfo("Tool content copied to clipboard")
207+ util.ReportInfo("Tool content copied to clipboard"),
208+ )
206}
209}
207
210
208func (m *toolCallCmp) formatToolForCopy() string {
211func (m *toolCallCmp) formatToolForCopy() string {
internal/tui/exp/list/list.go+398−5
@@ -13,6 +13,9 @@ import (
13 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/crush/internal/tui/util"
15 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
16+ uv "github.com/charmbracelet/ultraviolet"
17+ "github.com/charmbracelet/x/ansi"
18+ "github.com/rivo/uniseg"
16)
19)
17
20
18type Item interface {
21type Item interface {
@@ -46,6 +49,14 @@ type List[T Item] interface {
46 DeleteItem(string) tea.Cmd
49 DeleteItem(string) tea.Cmd
47 PrependItem(T) tea.Cmd
50 PrependItem(T) tea.Cmd
48 AppendItem(T) tea.Cmd
51 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
49}
60}
50
61
51type direction int
62type direction int
@@ -94,7 +105,13 @@ type list[T Item] struct {
94 renderMu sync.Mutex
105 renderMu sync.Mutex
95 rendered string
106 rendered string
96
107
97− movingByItem bool
108+ movingByItem bool
109+ selectionStartCol int
110+ selectionStartLine int
111+ selectionEndCol int
112+ selectionEndLine int
113+
114+ selectionActive bool
98}
115}
99
116
100type ListOption func(*confOptions)
117type ListOption func(*confOptions)
@@ -172,9 +189,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
172 keyMap: DefaultKeyMap(),
189 keyMap: DefaultKeyMap(),
173 focused: true,
190 focused: true,
174 },
191 },
175− items: csync.NewSliceFrom(items),
192+ items: csync.NewSliceFrom(items),
176− indexMap: csync.NewMap[string, int](),
193+ indexMap: csync.NewMap[string, int](),
177− renderedItems: csync.NewMap[string, renderedItem](),
194+ renderedItems: csync.NewMap[string, renderedItem](),
195+ selectionStartCol: -1,
196+ selectionStartLine: -1,
197+ selectionEndLine: -1,
198+ selectionEndCol: -1,
178 }
199 }
179 for _, opt := range opts {
200 for _, opt := range opts {
180 opt(list.confOptions)
201 opt(list.confOptions)
@@ -266,6 +287,157 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
266 return l, cmd
287 return l, cmd
267}
288}
268
289
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+
269// View implements List.
441// View implements List.
270func (l *list[T]) View() string {
442func (l *list[T]) View() string {
271 if l.height <= 0 || l.width <= 0 {
443 if l.height <= 0 || l.width <= 0 {
@@ -282,10 +454,16 @@ func (l *list[T]) View() string {
282 if l.resize {
454 if l.resize {
283 return strings.Join(lines, "\n")
455 return strings.Join(lines, "\n")
284 }
456 }
285− return t.S().Base.
457+ view = t.S().Base.
286 Height(l.height).
458 Height(l.height).
287 Width(l.width).
459 Width(l.width).
288 Render(strings.Join(lines, "\n"))
460 Render(strings.Join(lines, "\n"))
461+
462+ if !l.hasSelection() {
463+ return view
464+ }
465+
466+ return l.selectionView(view, false)
289}
467}
290
468
291func (l *list[T]) viewPosition() (int, int) {
469func (l *list[T]) viewPosition() (int, int) {
@@ -817,21 +995,67 @@ func (l *list[T]) decrementOffset(n int) {
817
995
818// MoveDown implements List.
996// MoveDown implements List.
819func (l *list[T]) MoveDown(n int) tea.Cmd {
997func (l *list[T]) MoveDown(n int) tea.Cmd {
998+ oldOffset := l.offset
820 if l.direction == DirectionForward {
999 if l.direction == DirectionForward {
821 l.incrementOffset(n)
1000 l.incrementOffset(n)
822 } else {
1001 } else {
823 l.decrementOffset(n)
1002 l.decrementOffset(n)
824 }
1003 }
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+ }
825 return l.changeSelectionWhenScrolling()
1026 return l.changeSelectionWhenScrolling()
826}
1027}
827
1028
828// MoveUp implements List.
1029// MoveUp implements List.
829func (l *list[T]) MoveUp(n int) tea.Cmd {
1030func (l *list[T]) MoveUp(n int) tea.Cmd {
1031+ oldOffset := l.offset
830 if l.direction == DirectionForward {
1032 if l.direction == DirectionForward {
831 l.decrementOffset(n)
1033 l.decrementOffset(n)
832 } else {
1034 } else {
833 l.incrementOffset(n)
1035 l.incrementOffset(n)
834 }
1036 }
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+ }
835 return l.changeSelectionWhenScrolling()
1059 return l.changeSelectionWhenScrolling()
836}
1060}
837
1061
@@ -1029,3 +1253,172 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1029 }
1253 }
1030 return tea.Sequence(cmds...)
1254 return tea.Sequence(cmds...)
1031}
1255}
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 (
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
22+ "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
22 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
23 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
23 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
24 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
24 "github.com/charmbracelet/crush/internal/tui/components/completions"
25 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -165,12 +166,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165 p.keyboardEnhancements = msg
166 p.keyboardEnhancements = msg
166 return p, nil
167 return p, nil
167 case tea.MouseWheelMsg:
168 case tea.MouseWheelMsg:
169+ if p.compact {
170+ msg.Y -= 1
171+ }
168− if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
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 {
169 u, cmd := p.chat.Update(msg)
209 u, cmd := p.chat.Update(msg)
170 p.chat = u.(chat.MessageListCmp)
210 p.chat = u.(chat.MessageListCmp)
171 return p, cmd
211 return p, cmd
172 }
212 }
173 return p, nil
213 return p, nil
214+ case chat.SelectionCopyMsg:
215+ u, cmd := p.chat.Update(msg)
216+ p.chat = u.(chat.MessageListCmp)
217+ return p, cmd
174 case tea.WindowSizeMsg:
218 case tea.WindowSizeMsg:
175 u, cmd := p.editor.Update(msg)
219 u, cmd := p.editor.Update(msg)
176 p.editor = u.(editor.Editor)
220 p.editor = u.(editor.Editor)
@@ -838,10 +882,7 @@ func (p *chatPage) Help() help.KeyMap {
838 key.WithKeys("up", "down"),
882 key.WithKeys("up", "down"),
839 key.WithHelp("↑↓", "scroll"),
883 key.WithHelp("↑↓", "scroll"),
840 ),
884 ),
841− key.NewBinding(
885+ messages.CopyKey,
842− key.WithKeys("c", "y"),
843− key.WithHelp("c/y", "copy"),
844− ),
845 )
886 )
846 fullList = append(fullList,
887 fullList = append(fullList,
847 []key.Binding{
888 []key.Binding{
@@ -880,6 +921,10 @@ func (p *chatPage) Help() help.KeyMap {
880 key.WithHelp("G", "end"),
921 key.WithHelp("G", "end"),
881 ),
922 ),
882 },
923 },
924+ []key.Binding{
925+ messages.CopyKey,
926+ messages.ClearSelectionKey,
927+ },
883 )
928 )
884 case PanelTypeEditor:
929 case PanelTypeEditor:
885 newLineBinding := key.NewBinding(
930 newLineBinding := key.NewBinding(
internal/tui/styles/charmtone.go+3
@@ -56,6 +56,9 @@ func NewCharmtoneTheme() *Theme {
56 Cherry: charmtone.Cherry,
56 Cherry: charmtone.Cherry,
57 }
57 }
58
58
59+ // Text selection.
60+ t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
61+
59 // LSP and MCP status.
62 // LSP and MCP status.
60 t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
63 t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
61 t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
64 t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
internal/tui/styles/icons.go+23
@@ -15,4 +15,27 @@ const (
15 ToolPending string = "●"
15 ToolPending string = "●"
16 ToolSuccess string = "✓"
16 ToolSuccess string = "✓"
17 ToolError string = "×"
17 ToolError string = "×"
18+
19+ BorderThin string = "│"
20+ BorderThick string = "▌"
18)
21)
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 {
74 RedLight color.Color
74 RedLight color.Color
75 Cherry color.Color
75 Cherry color.Color
76
76
77+ // Text selection.
78+ TextSelection lipgloss.Style
79+
77 // LSP and MCP status indicators.
80 // LSP and MCP status indicators.
78 ItemOfflineIcon lipgloss.Style
81 ItemOfflineIcon lipgloss.Style
79 ItemBusyIcon lipgloss.Style
82 ItemBusyIcon lipgloss.Style
internal/tui/tui.go+1−1
@@ -41,7 +41,7 @@ func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
41 case tea.MouseWheelMsg, tea.MouseMotionMsg:
41 case tea.MouseWheelMsg, tea.MouseMotionMsg:
42 now := time.Now()
42 now := time.Now()
43 // trackpad is sending too many requests
43 // trackpad is sending too many requests
44− if now.Sub(lastMouseEvent) < 5*time.Millisecond {
44+ if now.Sub(lastMouseEvent) < 15*time.Millisecond {
45 return nil
45 return nil
46 }
46 }
47 lastMouseEvent = now
47 lastMouseEvent = now