on main help sign in
Merge pull request #577 from charmbracelet/list-select-pp
77ac083Kujtim Hoxha
go.mod+5−5
@@ -10,16 +10,16 @@ require (
10 github.com/anthropics/anthropic-sdk-go v1.6.2
10 github.com/anthropics/anthropic-sdk-go v1.6.2
11 github.com/atotto/clipboard v0.1.4
11 github.com/atotto/clipboard v0.1.4
12 github.com/aymanbagabas/go-udiff v0.3.1
12 github.com/aymanbagabas/go-udiff v0.3.1
13− github.com/bmatcuk/doublestar/v4 v4.9.0
13+ github.com/bmatcuk/doublestar/v4 v4.9.1
14 github.com/charlievieth/fastwalk v1.0.11
14 github.com/charlievieth/fastwalk v1.0.11
15 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
15 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
16− github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d
16+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250805190305-70e94a2e0b2d
17− github.com/charmbracelet/catwalk v0.4.5
17+ github.com/charmbracelet/catwalk v0.4.6
18 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
18 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
19 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
19 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
20 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
20 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
21 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
21 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706
22− github.com/charmbracelet/x/ansi v0.9.3
22+ github.com/charmbracelet/x/ansi v0.10.0
23 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
23 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3
24 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
24 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a
25 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
25 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
@@ -75,7 +75,7 @@ require (
75 github.com/bahlo/generic-list-go v0.2.0 // indirect
75 github.com/bahlo/generic-list-go v0.2.0 // indirect
76 github.com/buger/jsonparser v1.1.1 // indirect
76 github.com/buger/jsonparser v1.1.1 // indirect
77 github.com/charmbracelet/colorprofile v0.3.1 // indirect
77 github.com/charmbracelet/colorprofile v0.3.1 // indirect
78− github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5
78+ github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d
79 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
80 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
80 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
81 github.com/charmbracelet/x/term v0.2.1
81 github.com/charmbracelet/x/term v0.2.1
go.sum+10−10
@@ -68,18 +68,18 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
68github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
68github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
69github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
69github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
70github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
70github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
71−github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
71+github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
72−github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
72+github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
73github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
73github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
74github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
74github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
75github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
75github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
76github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
76github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
77github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
77github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
78github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
78github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
79−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
79+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250805190305-70e94a2e0b2d h1:1C2whi5rgs+APtsQDE4riA+W5neYaMNY+Y+5o6J/rDU=
80−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
80+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250805190305-70e94a2e0b2d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
81−github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g=
81+github.com/charmbracelet/catwalk v0.4.6 h1:Y0JDq5V4agK8oO3lKC/hhInrYXePGwZPNo8I1Lk08jc=
82−github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
82+github.com/charmbracelet/catwalk v0.4.6/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
83github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
83github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
84github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
84github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
85github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
85github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -90,10 +90,10 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
90github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0/go.mod h1:XIuqKpZTUXtVyeyiN1k9Tc/U7EzfaDnVc34feFHfBws=
90github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0/go.mod h1:XIuqKpZTUXtVyeyiN1k9Tc/U7EzfaDnVc34feFHfBws=
91github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
91github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
92github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
92github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
93−github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5 h1:FrEzjuUbVbGd8UtZBfK8mf/IA4ExT2i3/fi+SEOv2eM=
93+github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d h1:miSXsyi0ARm35O+DulTdaLYoUioWdGkoBfFoIhdqpCA=
94−github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
94+github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
95−github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
95+github.com/charmbracelet/x/ansi v0.10.0 h1:jnOP9pFxY6/gw5nYjkpi6f17K0P/sN4fqT0Y1ioaORI=
96−github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
96+github.com/charmbracelet/x/ansi v0.10.0/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
97github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
97github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
98github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
98github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
99github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
99github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50=
internal/tui/components/chat/chat.go+46−19
@@ -2,7 +2,6 @@ package chat
2
2
3import (
3import (
4 "context"
4 "context"
5− "fmt"
6 "time"
5 "time"
7
6
8 "github.com/atotto/clipboard"
7 "github.com/atotto/clipboard"
@@ -45,7 +44,7 @@ type MessageListCmp interface {
45 SetSession(session.Session) tea.Cmd
44 SetSession(session.Session) tea.Cmd
46 GoToBottom() tea.Cmd
45 GoToBottom() tea.Cmd
47 GetSelectedText() string
46 GetSelectedText() string
48− CopySelectedText() tea.Cmd
47+ CopySelectedText(bool) tea.Cmd
49}
48}
50
49
51// messageListCmp implements MessageListCmp, providing a virtualized list
50// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -96,6 +95,10 @@ func (m *messageListCmp) Init() tea.Cmd {
96// Update handles incoming messages and updates the component state.
95// Update handles incoming messages and updates the component state.
97func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98 switch msg := msg.(type) {
97 switch msg := msg.(type) {
98+ case tea.KeyPressMsg:
99+ if key.Matches(msg, messages.CopyKey) && m.listCmp.HasSelection() {
100+ return m, m.CopySelectedText(true)
101+ }
99 case tea.MouseClickMsg:
102 case tea.MouseClickMsg:
100 x := msg.X - 1 // Adjust for padding
103 x := msg.X - 1 // Adjust for padding
101 y := msg.Y - 1 // Adjust for padding
104 y := msg.Y - 1 // Adjust for padding
@@ -128,11 +131,10 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128 if msg.Button == tea.MouseLeft {
131 if msg.Button == tea.MouseLeft {
129 if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
132 if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
130 m.listCmp.SelectionStop()
133 m.listCmp.SelectionStop()
131− return m, m.CopySelectedText()
134+ } else {
135+ m.listCmp.EndSelection(x, y)
136+ m.listCmp.SelectionStop()
132 }
137 }
133− m.listCmp.EndSelection(x, y)
134− m.listCmp.SelectionStop()
135− return m, m.CopySelectedText()
136 }
138 }
137 return m, nil
139 return m, nil
138 case pubsub.Event[permission.PermissionNotification]:
140 case pubsub.Event[permission.PermissionNotification]:
@@ -155,13 +157,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
155 u, cmd := m.listCmp.Update(msg)
157 u, cmd := m.listCmp.Update(msg)
156 m.listCmp = u.(list.List[list.Item])
158 m.listCmp = u.(list.List[list.Item])
157 return m, cmd
159 return m, cmd
158− default:
159− var cmds []tea.Cmd
160− u, cmd := m.listCmp.Update(msg)
161− m.listCmp = u.(list.List[list.Item])
162− cmds = append(cmds, cmd)
163− return m, tea.Batch(cmds...)
164 }
160 }
161+
162+ u, cmd := m.listCmp.Update(msg)
163+ m.listCmp = u.(list.List[list.Item])
164+ return m, cmd
165}
165}
166
166
167// View renders the message list or an initial screen if empty.
167// View renders the message list or an initial screen if empty.
@@ -654,24 +654,51 @@ func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
654 return nil
654 return nil
655}
655}
656
656
657+// SelectionClear clears the current selection in the list component.
658+func (m *messageListCmp) SelectionClear() tea.Cmd {
659+ m.listCmp.SelectionClear()
660+ m.previousSelected = ""
661+ m.lastClickX, m.lastClickY = 0, 0
662+ m.clickCount = 0
663+ return nil
664+}
665+
666+// HasSelection checks if there is a selection in the list component.
667+func (m *messageListCmp) HasSelection() bool {
668+ return m.listCmp.HasSelection()
669+}
670+
657// GetSelectedText returns the currently selected text from the list component.
671// GetSelectedText returns the currently selected text from the list component.
658func (m *messageListCmp) GetSelectedText() string {
672func (m *messageListCmp) GetSelectedText() string {
659 return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
673 return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
660}
674}
661
675
662−// CopySelectedText copies the currently selected text to the clipboard.
676+// CopySelectedText copies the currently selected text to the clipboard. When
677+// clear is true, it clears the selection after copying.
663−func (m *messageListCmp) CopySelectedText() tea.Cmd {
678+func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
679+ if !m.listCmp.HasSelection() {
664− return nil
680+ return nil
681+ }
682+
665 selectedText := m.GetSelectedText()
683 selectedText := m.GetSelectedText()
666 if selectedText == "" {
684 if selectedText == "" {
667 return util.ReportInfo("No text selected")
685 return util.ReportInfo("No text selected")
668 }
686 }
669
687
670− err := clipboard.WriteAll(selectedText)
688+ if clear {
671− if err != nil {
689+ defer func() { m.SelectionClear() }()
672− return util.ReportError(fmt.Errorf("failed to copy selected text to clipboard: %w", err))
673 }
690 }
691+
692+ return tea.Sequence(
693+ // We use both OSC 52 and native clipboard for compatibility with different
694+ // terminal emulators and environments.
695+ tea.SetClipboard(selectedText),
696+ func() tea.Msg {
697+ _ = clipboard.WriteAll(selectedText)
698+ return nil
699+ },
674− return util.ReportInfo("Selected text copied to clipboard")
700+ util.ReportInfo("Selected text copied to clipboard"),
701+ )
675}
702}
676
703
677// abs returns the absolute value of an integer.
704// abs returns the absolute value of an integer.
internal/tui/components/chat/messages/messages.go+11−7
@@ -25,7 +25,8 @@ 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"))
29
30
30// MessageCmp defines the interface for message components in the chat interface.
31// MessageCmp defines the interface for message components in the chat interface.
31// It combines standard UI model interfaces with message-specific functionality.
32// It combines standard UI model interfaces with message-specific functionality.
@@ -99,12 +100,15 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99 return m, cmd
100 return m, cmd
100 }
101 }
101 case tea.KeyPressMsg:
102 case tea.KeyPressMsg:
102− if key.Matches(msg, copyKey) {
103+ if key.Matches(msg, CopyKey) {
104+ return m, tea.Sequence(
105+ tea.SetClipboard(m.message.Content().Text),
106+ func() tea.Msg {
103− err := clipboard.WriteAll(m.message.Content().Text)
107+ _ = clipboard.WriteAll(m.message.Content().Text)
104− if err != nil {
108+ return nil
105− return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
106− }
109+ },
107− return m, util.ReportInfo("Message copied to clipboard")
110+ util.ReportInfo("Message copied to clipboard"),
111+ )
108 }
112 }
109 }
113 }
110 return m, nil
114 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/components/dialogs/permissions/keys.go+1−1
@@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap {
42 key.WithHelp("s", "allow session"),
42 key.WithHelp("s", "allow session"),
43 ),
43 ),
44 Deny: key.NewBinding(
44 Deny: key.NewBinding(
45− key.WithKeys("d", "D", "ctrl+d"),
45+ key.WithKeys("d", "D", "ctrl+d", "esc"),
46 key.WithHelp("d", "deny"),
46 key.WithHelp("d", "deny"),
47 ),
47 ),
48 Select: key.NewBinding(
48 Select: key.NewBinding(
internal/tui/components/mcp/mcp.go+1−1
@@ -68,7 +68,7 @@ func RenderMCPList(opts RenderOptions) []string {
68 case agent.MCPStateConnected:
68 case agent.MCPStateConnected:
69 icon = t.ItemOnlineIcon
69 icon = t.ItemOnlineIcon
70 if state.ToolCount > 0 {
70 if state.ToolCount > 0 {
71− extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount))
71+ extraContent = t.S().Subtle.Render(fmt.Sprintf("%d tools", state.ToolCount))
72 }
72 }
73 case agent.MCPStateError:
73 case agent.MCPStateError:
74 icon = t.ItemErrorIcon
74 icon = t.ItemErrorIcon
internal/tui/exp/list/list.go+69−87
@@ -56,6 +56,7 @@ type List[T Item] interface {
56 SelectWord(col, line int)
56 SelectWord(col, line int)
57 SelectParagraph(col, line int)
57 SelectParagraph(col, line int)
58 GetSelectedText(paddingLeft int) string
58 GetSelectedText(paddingLeft int) string
59+ HasSelection() bool
59}
60}
60
61
61type direction int
62type direction int
@@ -286,30 +287,10 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
286 return l, cmd
287 return l, cmd
287}
288}
288
289
289−// View implements List.
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.
290−func (l *list[T]) View() string {
292+func (l *list[T]) selectionView(view string, textOnly bool) string {
291− if l.height <= 0 || l.width <= 0 {
292− return ""
293− }
294 t := styles.CurrentTheme()
293 t := styles.CurrentTheme()
295− view := l.rendered
296− lines := strings.Split(view, "\n")
297−
298− start, end := l.viewPosition()
299− viewStart := max(0, start)
300− viewEnd := min(len(lines), end+1)
301− lines = lines[viewStart:viewEnd]
302− if l.resize {
303− return strings.Join(lines, "\n")
304− }
305− view = t.S().Base.
306− Height(l.height).
307− Width(l.width).
308− Render(strings.Join(lines, "\n"))
309−
310− if !l.hasSelection() {
311− return view
312− }
313 area := uv.Rect(0, 0, l.width, l.height)
294 area := uv.Rect(0, 0, l.width, l.height)
314 scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
295 scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
315 uv.NewStyledString(view).Draw(scr, area)
296 uv.NewStyledString(view).Draw(scr, area)
@@ -397,6 +378,8 @@ func (l *list[T]) View() string {
397 lineTextBounds[y] = bounds
378 lineTextBounds[y] = bounds
398 }
379 }
399
380
381+ var selectedText strings.Builder
382+
400 // Second pass: apply selection highlighting
383 // Second pass: apply selection highlighting
401 for y := range scr.Height() {
384 for y := range scr.Height() {
402 selBounds := lineSelections[y]
385 selBounds := lineSelections[y]
@@ -406,6 +389,11 @@ func (l *list[T]) View() string {
406
389
407 textBounds := lineTextBounds[y]
390 textBounds := lineTextBounds[y]
408 if textBounds.start < 0 {
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+
409 continue // No text on this line
397 continue // No text on this line
410 }
398 }
411
399
@@ -421,16 +409,63 @@ func (l *list[T]) View() string {
421
409
422 cellStr := cell.String()
410 cellStr := cell.String()
423 if len(cellStr) > 0 && !specialChars[cellStr] {
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+
424 cell = cell.Clone()
422 cell = cell.Clone()
425− cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White)
423+ cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
426 scr.SetCell(x, y, cell)
424 scr.SetCell(x, y, cell)
427 }
425 }
428 }
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())
429 }
436 }
430
437
431 return scr.Render()
438 return scr.Render()
432}
439}
433
440
441+// View implements List.
442+func (l *list[T]) View() string {
443+ if l.height <= 0 || l.width <= 0 {
444+ return ""
445+ }
446+ t := styles.CurrentTheme()
447+ view := l.rendered
448+ lines := strings.Split(view, "\n")
449+
450+ start, end := l.viewPosition()
451+ viewStart := max(0, start)
452+ viewEnd := min(len(lines), end+1)
453+ lines = lines[viewStart:viewEnd]
454+ if l.resize {
455+ return strings.Join(lines, "\n")
456+ }
457+ view = t.S().Base.
458+ Height(l.height).
459+ Width(l.width).
460+ Render(strings.Join(lines, "\n"))
461+
462+ if !l.hasSelection() {
463+ return view
464+ }
465+
466+ return l.selectionView(view, false)
467+}
468+
434func (l *list[T]) viewPosition() (int, int) {
469func (l *list[T]) viewPosition() (int, int) {
435 start, end := 0, 0
470 start, end := 0, 0
436 renderedLines := lipgloss.Height(l.rendered) - 1
471 renderedLines := lipgloss.Height(l.rendered) - 1
@@ -1374,69 +1409,16 @@ func (l *list[T]) SelectParagraph(col, line int) {
1374 l.selectionActive = false // Not actively selecting, just selected
1409 l.selectionActive = false // Not actively selecting, just selected
1375}
1410}
1376
1411
1412+// HasSelection returns whether there is an active selection.
1413+func (l *list[T]) HasSelection() bool {
1414+ return l.hasSelection()
1415+}
1416+
1377// GetSelectedText returns the currently selected text.
1417// GetSelectedText returns the currently selected text.
1378func (l *list[T]) GetSelectedText(paddingLeft int) string {
1418func (l *list[T]) GetSelectedText(paddingLeft int) string {
1379− return ""
1380− // if !l.hasSelection() {
1419+ if !l.hasSelection() {
1381− // return ""
1420+ return ""
1382− // }
1421+ }
1383− //
1422+
1384− // startLine := l.selectionStartLine
1423+ return l.selectionView(l.View(), true)
1385− // endLine := l.selectionEndLine
1386− // startCol := l.selectionStartCol
1387− // endCol := l.selectionEndCol
1388− //
1389− // if l.direction == DirectionBackward {
1390− // startLine = (lipgloss.Height(l.rendered) - 1) - startLine
1391− // endLine = (lipgloss.Height(l.rendered) - 1) - endLine
1392− // }
1393− //
1394− // if l.offset > 0 {
1395− // if l.direction == DirectionBackward {
1396− // startLine += l.offset
1397− // endLine += l.offset
1398− // } else {
1399− // startLine -= l.offset
1400− // endLine -= l.offset
1401− // }
1402− // }
1403− //
1404− // lines := strings.Split(l.rendered, "\n")
1405− //
1406− // if startLine < 0 || endLine < 0 || startLine >= len(lines) || endLine >= len(lines) {
1407− // return ""
1408− // }
1409− //
1410− // var result strings.Builder
1411− // for i := range lines {
1412− // lines[i] = ansi.Strip(lines[i])
1413− // for _, icon := range styles.SelectionIgnoreIcons {
1414− // lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1415− // }
1416− //
1417− // if i == startLine {
1418− // if startCol < 0 || startCol >= len(lines[i]) {
1419− // startCol = 0
1420− // }
1421− // if startCol < paddingLeft {
1422− // startCol = paddingLeft
1423− // }
1424− // if i != endLine {
1425− // endCol = len(lines[i])
1426− // }
1427− // result.WriteString(strings.TrimRightFunc(lines[i][startCol:endCol], unicode.IsSpace))
1428− // } else if i > startLine && i < endLine {
1429− // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:], unicode.IsSpace))
1430− // } else if i == endLine {
1431− // if endCol < 0 || endCol >= len(lines[i]) {
1432− // endCol = len(lines[i])
1433− // }
1434− // if endCol < paddingLeft {
1435− // endCol = paddingLeft
1436− // }
1437− // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:endCol], unicode.IsSpace))
1438− // }
1439− // }
1440− //
1441− // return result.String()
1442}
1424}
internal/tui/page/chat/chat.go+11−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"
@@ -172,10 +173,18 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172 }
173 }
173 return p, nil
174 return p, nil
174 case tea.MouseClickMsg:
175 case tea.MouseClickMsg:
176+ if p.isMouseOverChat(msg.X, msg.Y) {
177+ p.focusedPane = PanelTypeChat
178+ p.chat.Focus()
179+ p.editor.Blur()
180+ } else {
181+ p.focusedPane = PanelTypeEditor
182+ p.editor.Focus()
183+ p.chat.Blur()
184+ }
175 u, cmd := p.chat.Update(msg)
185 u, cmd := p.chat.Update(msg)
176 p.chat = u.(chat.MessageListCmp)
186 p.chat = u.(chat.MessageListCmp)
177 return p, cmd
187 return p, cmd
178− return p, nil
179 case tea.MouseMotionMsg:
188 case tea.MouseMotionMsg:
180 if msg.Button == tea.MouseLeft {
189 if msg.Button == tea.MouseLeft {
181 u, cmd := p.chat.Update(msg)
190 u, cmd := p.chat.Update(msg)
@@ -857,10 +866,7 @@ func (p *chatPage) Help() help.KeyMap {
857 key.WithKeys("up", "down"),
866 key.WithKeys("up", "down"),
858 key.WithHelp("↑↓", "scroll"),
867 key.WithHelp("↑↓", "scroll"),
859 ),
868 ),
860− key.NewBinding(
869+ messages.CopyKey,
861− key.WithKeys("c", "y"),
862− key.WithHelp("c/y", "copy"),
863− ),
864 )
870 )
865 fullList = append(fullList,
871 fullList = append(fullList,
866 []key.Binding{
872 []key.Binding{
internal/tui/styles/crush.go → internal/tui/styles/charmtone.gorenamed+5−2
@@ -5,9 +5,9 @@ import (
5 "github.com/charmbracelet/x/exp/charmtone"
5 "github.com/charmbracelet/x/exp/charmtone"
6)
6)
7
7
8−func NewCrushTheme() *Theme {
8+func NewCharmtoneTheme() *Theme {
9 t := &Theme{
9 t := &Theme{
10− Name: "crush",
10+ Name: "charmtone",
11 IsDark: true,
11 IsDark: true,
12
12
13 Primary: charmtone.Charple,
13 Primary: charmtone.Charple,
@@ -56,6 +56,9 @@ func NewCrushTheme() *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/theme.go+9−6
@@ -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
@@ -491,26 +494,26 @@ func SetDefaultManager(m *Manager) {
491
494
492func DefaultManager() *Manager {
495func DefaultManager() *Manager {
493 if defaultManager == nil {
496 if defaultManager == nil {
494− defaultManager = NewManager("crush")
497+ defaultManager = NewManager()
495 }
498 }
496 return defaultManager
499 return defaultManager
497}
500}
498
501
499func CurrentTheme() *Theme {
502func CurrentTheme() *Theme {
500 if defaultManager == nil {
503 if defaultManager == nil {
501− defaultManager = NewManager("crush")
504+ defaultManager = NewManager()
502 }
505 }
503 return defaultManager.Current()
506 return defaultManager.Current()
504}
507}
505
508
506−func NewManager(defaultTheme string) *Manager {
509+func NewManager() *Manager {
507 m := &Manager{
510 m := &Manager{
508 themes: make(map[string]*Theme),
511 themes: make(map[string]*Theme),
509 }
512 }
510
513
514+ t := NewCharmtoneTheme() // default theme
511− m.Register(NewCrushTheme())
515+ m.Register(t)
512−
513− m.current = m.themes[defaultTheme]
516+ m.current = m.themes[t.Name]
514
517
515 return m
518 return m
516}
519}