From 3d91fd0fa25a65f30086b800a5bdd91122e308f1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 5 Aug 2025 16:26:10 -0400 Subject: [PATCH] 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. --- internal/tui/components/chat/chat.go | 65 +++++++++++++------ .../tui/components/chat/messages/messages.go | 13 ++-- internal/tui/components/chat/messages/tool.go | 13 ++-- 3 files changed, 62 insertions(+), 29 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index b1af201f33be840fa626244a42aa2d88fa18649c..22ef7099b505977432c650655ec9a30c098f85dd 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -2,7 +2,6 @@ package chat import ( "context" - "fmt" "time" "github.com/atotto/clipboard" @@ -45,7 +44,7 @@ type MessageListCmp interface { SetSession(session.Session) tea.Cmd GoToBottom() tea.Cmd GetSelectedText() string - CopySelectedText() tea.Cmd + CopySelectedText(bool) tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -96,6 +95,10 @@ func (m *messageListCmp) Init() tea.Cmd { // Update handles incoming messages and updates the component state. func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.KeyPressMsg: + if key.Matches(msg, messages.CopyKey) && m.listCmp.HasSelection() { + return m, m.CopySelectedText(true) + } case tea.MouseClickMsg: x := msg.X - 1 // Adjust for padding y := msg.Y - 1 // Adjust for padding @@ -128,11 +131,10 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Button == tea.MouseLeft { if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { m.listCmp.SelectionStop() - return m, m.CopySelectedText() + } else { + m.listCmp.EndSelection(x, y) + m.listCmp.SelectionStop() } - m.listCmp.EndSelection(x, y) - m.listCmp.SelectionStop() - return m, m.CopySelectedText() } return m, nil case pubsub.Event[permission.PermissionNotification]: @@ -155,13 +157,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { u, cmd := m.listCmp.Update(msg) m.listCmp = u.(list.List[list.Item]) return m, cmd - default: - var cmds []tea.Cmd - u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.List[list.Item]) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } + + u, cmd := m.listCmp.Update(msg) + m.listCmp = u.(list.List[list.Item]) + return m, cmd } // View renders the message list or an initial screen if empty. @@ -654,24 +654,51 @@ func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd { return nil } +// SelectionClear clears the current selection in the list component. +func (m *messageListCmp) SelectionClear() tea.Cmd { + m.listCmp.SelectionClear() + m.previousSelected = "" + m.lastClickX, m.lastClickY = 0, 0 + m.clickCount = 0 + return nil +} + +// HasSelection checks if there is a selection in the list component. +func (m *messageListCmp) HasSelection() bool { + return m.listCmp.HasSelection() +} + // GetSelectedText returns the currently selected text from the list component. func (m *messageListCmp) GetSelectedText() string { return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding } -// CopySelectedText copies the currently selected text to the clipboard. -func (m *messageListCmp) CopySelectedText() tea.Cmd { - return nil +// CopySelectedText copies the currently selected text to the clipboard. When +// clear is true, it clears the selection after copying. +func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd { + if !m.listCmp.HasSelection() { + return nil + } + selectedText := m.GetSelectedText() if selectedText == "" { return util.ReportInfo("No text selected") } - err := clipboard.WriteAll(selectedText) - if err != nil { - return util.ReportError(fmt.Errorf("failed to copy selected text to clipboard: %w", err)) + if clear { + defer func() { m.SelectionClear() }() } - return util.ReportInfo("Selected text copied to clipboard") + + return tea.Sequence( + // We use both OSC 52 and native clipboard for compatibility with different + // terminal emulators and environments. + tea.SetClipboard(selectedText), + func() tea.Msg { + _ = clipboard.WriteAll(selectedText) + return nil + }, + util.ReportInfo("Selected text copied to clipboard"), + ) } // abs returns the absolute value of an integer. diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 891f4e483fcc7b9cdb6d82485998329995d542a6..bc594ab5e1240ff004bdb9548afa872ac1bb62e8 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -101,11 +101,14 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyPressMsg: if key.Matches(msg, CopyKey) { - err := clipboard.WriteAll(m.message.Content().Text) - if err != nil { - return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err)) - } - return m, util.ReportInfo("Message copied to clipboard") + return m, tea.Sequence( + tea.SetClipboard(m.message.Content().Text), + func() tea.Msg { + _ = clipboard.WriteAll(m.message.Content().Text) + return nil + }, + util.ReportInfo("Message copied to clipboard"), + ) } } return m, nil diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 41bb7b81d0e9fa202e98bd91aca4f2eddf9c22c1..7e03674f97243e7d9e569b341fe1c6f1d2450b93 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -198,11 +198,14 @@ func (m *toolCallCmp) SetCancelled() { func (m *toolCallCmp) copyTool() tea.Cmd { content := m.formatToolForCopy() - err := clipboard.WriteAll(content) - if err != nil { - return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err)) - } - return util.ReportInfo("Tool content copied to clipboard") + return tea.Sequence( + tea.SetClipboard(content), + func() tea.Msg { + _ = clipboard.WriteAll(content) + return nil + }, + util.ReportInfo("Tool content copied to clipboard"), + ) } func (m *toolCallCmp) formatToolForCopy() string {