on main help sign in
feat(chat): copy selected text in chat messages via shared key binding

This uses both OSC 52 and native clipboard for maximum compatibility with different terminal emulators and environments. The `CopySelectedText` method now accepts a boolean parameter to clear the selection after copying.

commit3d91fd0fa25a65f30086b800a5bdd91122e308f1 authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 5 Aug 2025, 16:26 (1 year ago) parent1f8bc4e
3 files changed+62−29
internal/tui/components/chat/chat.go+46−19 internal/tui/components/chat/messages/messages.go+8−5 internal/tui/components/chat/messages/tool.go+8−5
internal/tui/components/chat/chat.go+46−19
@@ -2,7 +2,6 @@ package chat
22
33import (
44 "context"
5− "fmt"
65 "time"
76
87 "github.com/atotto/clipboard"
@@ -45,7 +44,7 @@ type MessageListCmp interface {
4544 SetSession(session.Session) tea.Cmd
4645 GoToBottom() tea.Cmd
4746 GetSelectedText() string
48− CopySelectedText() tea.Cmd
47+ CopySelectedText(bool) tea.Cmd
4948}
5049
5150// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -96,6 +95,10 @@ func (m *messageListCmp) Init() tea.Cmd {
9695// Update handles incoming messages and updates the component state.
9796func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9897 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+ }
99102 case tea.MouseClickMsg:
100103 x := msg.X - 1 // Adjust for padding
101104 y := msg.Y - 1 // Adjust for padding
@@ -128,11 +131,10 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128131 if msg.Button == tea.MouseLeft {
129132 if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
130133 m.listCmp.SelectionStop()
131− return m, m.CopySelectedText()
134+ } else {
135+ m.listCmp.EndSelection(x, y)
136+ m.listCmp.SelectionStop()
132137 }
133− m.listCmp.EndSelection(x, y)
134− m.listCmp.SelectionStop()
135− return m, m.CopySelectedText()
136138 }
137139 return m, nil
138140 case pubsub.Event[permission.PermissionNotification]:
@@ -155,13 +157,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
155157 u, cmd := m.listCmp.Update(msg)
156158 m.listCmp = u.(list.List[list.Item])
157159 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...)
164160 }
161+
162+ u, cmd := m.listCmp.Update(msg)
163+ m.listCmp = u.(list.List[list.Item])
164+ return m, cmd
165165}
166166
167167// View renders the message list or an initial screen if empty.
@@ -654,24 +654,51 @@ func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
654654 return nil
655655}
656656
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+
657671// GetSelectedText returns the currently selected text from the list component.
658672func (m *messageListCmp) GetSelectedText() string {
659673 return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
660674}
661675
662−// CopySelectedText copies the currently selected text to the clipboard.
663−func (m *messageListCmp) CopySelectedText() tea.Cmd {
664− return nil
676+// CopySelectedText copies the currently selected text to the clipboard. When
677+// clear is true, it clears the selection after copying.
678+func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
679+ if !m.listCmp.HasSelection() {
680+ return nil
681+ }
682+
665683 selectedText := m.GetSelectedText()
666684 if selectedText == "" {
667685 return util.ReportInfo("No text selected")
668686 }
669687
670− err := clipboard.WriteAll(selectedText)
671− if err != nil {
672− return util.ReportError(fmt.Errorf("failed to copy selected text to clipboard: %w", err))
688+ if clear {
689+ defer func() { m.SelectionClear() }()
673690 }
674− return util.ReportInfo("Selected text copied to clipboard")
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+ },
700+ util.ReportInfo("Selected text copied to clipboard"),
701+ )
675702}
676703
677704// abs returns the absolute value of an integer.
internal/tui/components/chat/messages/messages.go+8−5
@@ -101,11 +101,14 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101101 }
102102 case tea.KeyPressMsg:
103103 if key.Matches(msg, CopyKey) {
104− err := clipboard.WriteAll(m.message.Content().Text)
105− if err != nil {
106− return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
107− }
108− return m, util.ReportInfo("Message copied to clipboard")
104+ return m, tea.Sequence(
105+ tea.SetClipboard(m.message.Content().Text),
106+ func() tea.Msg {
107+ _ = clipboard.WriteAll(m.message.Content().Text)
108+ return nil
109+ },
110+ util.ReportInfo("Message copied to clipboard"),
111+ )
109112 }
110113 }
111114 return m, nil
internal/tui/components/chat/messages/tool.go+8−5
@@ -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 {