feat(chat): copy selected text in chat messages via shared key binding

Ayman Bagabas created

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.

Change summary

internal/tui/components/chat/chat.go              | 65 ++++++++++++----
internal/tui/components/chat/messages/messages.go | 13 ++-
internal/tui/components/chat/messages/tool.go     | 13 ++-
3 files changed, 62 insertions(+), 29 deletions(-)

Detailed changes

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.

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

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 {