on main help sign in
feat(chat): copy selected text in chat messages via shared key binding
3d91fd0Ayman Bagabas
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+8−5
@@ -101,11 +101,14 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 }
101 }
102 case tea.KeyPressMsg:
102 case tea.KeyPressMsg:
103 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 {
104− err := clipboard.WriteAll(m.message.Content().Text)
107+ _ = clipboard.WriteAll(m.message.Content().Text)
105− if err != nil {
108+ return nil
106− return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
107− }
109+ },
108− return m, util.ReportInfo("Message copied to clipboard")
110+ util.ReportInfo("Message copied to clipboard"),
111+ )
109 }
112 }
110 }
113 }
111 return m, nil
114 return m, nil
internal/tui/components/chat/messages/tool.go+8−5
@@ -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 {