feat(ui): add keybinding to copy chat message content to clipboard (#1947)

Ayman Bagabas created

* feat(ui): add keybinding to copy chat message content to clipboard

This commit backports the ability to copy the content of chat messages
(assistant, user, and tool messages) to the clipboard using the 'c' key
when the message is focused.

* feat(ui): format tool calls and results for clipboard copying

Change summary

internal/ui/chat/assistant.go |   9 
internal/ui/chat/messages.go  |   5 
internal/ui/chat/tools.go     | 595 +++++++++++++++++++++++++++++++++++++
internal/ui/chat/user.go      |  10 
internal/ui/common/common.go  |  25 +
internal/ui/model/chat.go     |  10 
internal/ui/model/ui.go       |  17 
7 files changed, 662 insertions(+), 9 deletions(-)

Detailed changes

internal/ui/chat/assistant.go 🔗

@@ -255,3 +255,12 @@ func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int)
 	}
 	return false
 }
+
+// HandleKeyEvent implements KeyEventHandler.
+func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
+	if key.String() == "c" {
+		text := a.message.Content().Text
+		return true, common.CopyToClipboard(text, "Message copied to clipboard")
+	}
+	return false, nil
+}

internal/ui/chat/messages.go 🔗

@@ -41,6 +41,11 @@ type Expandable interface {
 	ToggleExpanded()
 }
 
+// KeyEventHandler is an interface for items that can handle key events.
+type KeyEventHandler interface {
+	HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd)
+}
+
 // MessageItem represents a [message.Message] item that can be displayed in the
 // UI and be part of a [list.List] identifiable by a unique ID.
 type MessageItem interface {

internal/ui/chat/tools.go 🔗

@@ -1,14 +1,19 @@
 package chat
 
 import (
+	"encoding/json"
 	"fmt"
+	"path/filepath"
 	"strings"
+	"time"
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"charm.land/lipgloss/v2/tree"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -413,6 +418,15 @@ func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) b
 	return true
 }
 
+// HandleKeyEvent implements KeyEventHandler.
+func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
+	if key.String() == "c" {
+		text := t.formatToolForCopy()
+		return true, common.CopyToClipboard(text, "Tool content copied to clipboard")
+	}
+	return false, nil
+}
+
 // pendingTool renders a tool that is still in progress with an animation.
 func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
 	icon := sty.Tool.IconPending.Render()
@@ -807,3 +821,584 @@ func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, ex
 
 	return sty.Tool.Body.Render(strings.Join(out, "\n"))
 }
+
+// formatToolForCopy formats the tool call for clipboard copying.
+func (t *baseToolMessageItem) formatToolForCopy() string {
+	var parts []string
+
+	toolName := prettifyToolName(t.toolCall.Name)
+	parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
+
+	if t.toolCall.Input != "" {
+		params := t.formatParametersForCopy()
+		if params != "" {
+			parts = append(parts, "### Parameters:")
+			parts = append(parts, params)
+		}
+	}
+
+	if t.result != nil && t.result.ToolCallID != "" {
+		if t.result.IsError {
+			parts = append(parts, "### Error:")
+			parts = append(parts, t.result.Content)
+		} else {
+			parts = append(parts, "### Result:")
+			content := t.formatResultForCopy()
+			if content != "" {
+				parts = append(parts, content)
+			}
+		}
+	} else if t.status == ToolStatusCanceled {
+		parts = append(parts, "### Status:")
+		parts = append(parts, "Cancelled")
+	} else {
+		parts = append(parts, "### Status:")
+		parts = append(parts, "Pending...")
+	}
+
+	return strings.Join(parts, "\n\n")
+}
+
+// formatParametersForCopy formats tool parameters for clipboard copying.
+func (t *baseToolMessageItem) formatParametersForCopy() string {
+	switch t.toolCall.Name {
+	case tools.BashToolName:
+		var params tools.BashParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			cmd := strings.ReplaceAll(params.Command, "\n", " ")
+			cmd = strings.ReplaceAll(cmd, "\t", "    ")
+			return fmt.Sprintf("**Command:** %s", cmd)
+		}
+	case tools.ViewToolName:
+		var params tools.ViewParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
+			if params.Limit > 0 {
+				parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
+			}
+			if params.Offset > 0 {
+				parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.EditToolName:
+		var params tools.EditParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
+		}
+	case tools.MultiEditToolName:
+		var params tools.MultiEditParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
+			parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
+			return strings.Join(parts, "\n")
+		}
+	case tools.WriteToolName:
+		var params tools.WriteParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
+		}
+	case tools.FetchToolName:
+		var params tools.FetchParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
+			if params.Format != "" {
+				parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
+			}
+			if params.Timeout > 0 {
+				parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.AgenticFetchToolName:
+		var params tools.AgenticFetchParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			if params.URL != "" {
+				parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
+			}
+			if params.Prompt != "" {
+				parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.WebFetchToolName:
+		var params tools.WebFetchParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			return fmt.Sprintf("**URL:** %s", params.URL)
+		}
+	case tools.GrepToolName:
+		var params tools.GrepParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
+			if params.Path != "" {
+				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
+			}
+			if params.Include != "" {
+				parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
+			}
+			if params.LiteralText {
+				parts = append(parts, "**Literal:** true")
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.GlobToolName:
+		var params tools.GlobParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
+			if params.Path != "" {
+				parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.LSToolName:
+		var params tools.LSParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			path := params.Path
+			if path == "" {
+				path = "."
+			}
+			return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
+		}
+	case tools.DownloadToolName:
+		var params tools.DownloadParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
+			parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
+			if params.Timeout > 0 {
+				parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.SourcegraphToolName:
+		var params tools.SourcegraphParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			var parts []string
+			parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
+			if params.Count > 0 {
+				parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
+			}
+			if params.ContextWindow > 0 {
+				parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.DiagnosticsToolName:
+		return "**Project:** diagnostics"
+	case agent.AgentToolName:
+		var params agent.AgentParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			return fmt.Sprintf("**Task:**\n%s", params.Prompt)
+		}
+	}
+
+	var params map[string]any
+	if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+		var parts []string
+		for key, value := range params {
+			displayKey := strings.ReplaceAll(key, "_", " ")
+			if len(displayKey) > 0 {
+				displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
+			}
+			parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
+		}
+		return strings.Join(parts, "\n")
+	}
+
+	return ""
+}
+
+// formatResultForCopy formats tool results for clipboard copying.
+func (t *baseToolMessageItem) formatResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	if t.result.Data != "" {
+		if strings.HasPrefix(t.result.MIMEType, "image/") {
+			return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
+		}
+		return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
+	}
+
+	switch t.toolCall.Name {
+	case tools.BashToolName:
+		return t.formatBashResultForCopy()
+	case tools.ViewToolName:
+		return t.formatViewResultForCopy()
+	case tools.EditToolName:
+		return t.formatEditResultForCopy()
+	case tools.MultiEditToolName:
+		return t.formatMultiEditResultForCopy()
+	case tools.WriteToolName:
+		return t.formatWriteResultForCopy()
+	case tools.FetchToolName:
+		return t.formatFetchResultForCopy()
+	case tools.AgenticFetchToolName:
+		return t.formatAgenticFetchResultForCopy()
+	case tools.WebFetchToolName:
+		return t.formatWebFetchResultForCopy()
+	case agent.AgentToolName:
+		return t.formatAgentResultForCopy()
+	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
+		return fmt.Sprintf("```\n%s\n```", t.result.Content)
+	default:
+		return t.result.Content
+	}
+}
+
+// formatBashResultForCopy formats bash tool results for clipboard.
+func (t *baseToolMessageItem) formatBashResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var meta tools.BashResponseMetadata
+	if t.result.Metadata != "" {
+		json.Unmarshal([]byte(t.result.Metadata), &meta)
+	}
+
+	output := meta.Output
+	if output == "" && t.result.Content != tools.BashNoOutput {
+		output = t.result.Content
+	}
+
+	if output == "" {
+		return ""
+	}
+
+	return fmt.Sprintf("```bash\n%s\n```", output)
+}
+
+// formatViewResultForCopy formats view tool results for clipboard.
+func (t *baseToolMessageItem) formatViewResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var meta tools.ViewResponseMetadata
+	if t.result.Metadata != "" {
+		json.Unmarshal([]byte(t.result.Metadata), &meta)
+	}
+
+	if meta.Content == "" {
+		return t.result.Content
+	}
+
+	lang := ""
+	if meta.FilePath != "" {
+		ext := strings.ToLower(filepath.Ext(meta.FilePath))
+		switch ext {
+		case ".go":
+			lang = "go"
+		case ".js", ".mjs":
+			lang = "javascript"
+		case ".ts":
+			lang = "typescript"
+		case ".py":
+			lang = "python"
+		case ".rs":
+			lang = "rust"
+		case ".java":
+			lang = "java"
+		case ".c":
+			lang = "c"
+		case ".cpp", ".cc", ".cxx":
+			lang = "cpp"
+		case ".sh", ".bash":
+			lang = "bash"
+		case ".json":
+			lang = "json"
+		case ".yaml", ".yml":
+			lang = "yaml"
+		case ".xml":
+			lang = "xml"
+		case ".html":
+			lang = "html"
+		case ".css":
+			lang = "css"
+		case ".md":
+			lang = "markdown"
+		}
+	}
+
+	var result strings.Builder
+	if lang != "" {
+		result.WriteString(fmt.Sprintf("```%s\n", lang))
+	} else {
+		result.WriteString("```\n")
+	}
+	result.WriteString(meta.Content)
+	result.WriteString("\n```")
+
+	return result.String()
+}
+
+// formatEditResultForCopy formats edit tool results for clipboard.
+func (t *baseToolMessageItem) formatEditResultForCopy() string {
+	if t.result == nil || t.result.Metadata == "" {
+		if t.result != nil {
+			return t.result.Content
+		}
+		return ""
+	}
+
+	var meta tools.EditResponseMetadata
+	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
+		return t.result.Content
+	}
+
+	var params tools.EditParams
+	json.Unmarshal([]byte(t.toolCall.Input), &params)
+
+	var result strings.Builder
+
+	if meta.OldContent != "" || meta.NewContent != "" {
+		fileName := params.FilePath
+		if fileName != "" {
+			fileName = fsext.PrettyPath(fileName)
+		}
+		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
+
+		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+		result.WriteString("```diff\n")
+		result.WriteString(diffContent)
+		result.WriteString("\n```")
+	}
+
+	return result.String()
+}
+
+// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
+func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
+	if t.result == nil || t.result.Metadata == "" {
+		if t.result != nil {
+			return t.result.Content
+		}
+		return ""
+	}
+
+	var meta tools.MultiEditResponseMetadata
+	if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
+		return t.result.Content
+	}
+
+	var params tools.MultiEditParams
+	json.Unmarshal([]byte(t.toolCall.Input), &params)
+
+	var result strings.Builder
+	if meta.OldContent != "" || meta.NewContent != "" {
+		fileName := params.FilePath
+		if fileName != "" {
+			fileName = fsext.PrettyPath(fileName)
+		}
+		diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
+
+		result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
+		result.WriteString("```diff\n")
+		result.WriteString(diffContent)
+		result.WriteString("\n```")
+	}
+
+	return result.String()
+}
+
+// formatWriteResultForCopy formats write tool results for clipboard.
+func (t *baseToolMessageItem) formatWriteResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var params tools.WriteParams
+	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
+		return t.result.Content
+	}
+
+	lang := ""
+	if params.FilePath != "" {
+		ext := strings.ToLower(filepath.Ext(params.FilePath))
+		switch ext {
+		case ".go":
+			lang = "go"
+		case ".js", ".mjs":
+			lang = "javascript"
+		case ".ts":
+			lang = "typescript"
+		case ".py":
+			lang = "python"
+		case ".rs":
+			lang = "rust"
+		case ".java":
+			lang = "java"
+		case ".c":
+			lang = "c"
+		case ".cpp", ".cc", ".cxx":
+			lang = "cpp"
+		case ".sh", ".bash":
+			lang = "bash"
+		case ".json":
+			lang = "json"
+		case ".yaml", ".yml":
+			lang = "yaml"
+		case ".xml":
+			lang = "xml"
+		case ".html":
+			lang = "html"
+		case ".css":
+			lang = "css"
+		case ".md":
+			lang = "markdown"
+		}
+	}
+
+	var result strings.Builder
+	result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
+	if lang != "" {
+		result.WriteString(fmt.Sprintf("```%s\n", lang))
+	} else {
+		result.WriteString("```\n")
+	}
+	result.WriteString(params.Content)
+	result.WriteString("\n```")
+
+	return result.String()
+}
+
+// formatFetchResultForCopy formats fetch tool results for clipboard.
+func (t *baseToolMessageItem) formatFetchResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var params tools.FetchParams
+	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
+		return t.result.Content
+	}
+
+	var result strings.Builder
+	if params.URL != "" {
+		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+	}
+	if params.Format != "" {
+		result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
+	}
+	if params.Timeout > 0 {
+		result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
+	}
+	result.WriteString("\n")
+
+	result.WriteString(t.result.Content)
+
+	return result.String()
+}
+
+// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
+func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var params tools.AgenticFetchParams
+	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
+		return t.result.Content
+	}
+
+	var result strings.Builder
+	if params.URL != "" {
+		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+	}
+	if params.Prompt != "" {
+		result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
+	}
+
+	result.WriteString("```markdown\n")
+	result.WriteString(t.result.Content)
+	result.WriteString("\n```")
+
+	return result.String()
+}
+
+// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
+func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var params tools.WebFetchParams
+	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
+		return t.result.Content
+	}
+
+	var result strings.Builder
+	result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
+	result.WriteString("```markdown\n")
+	result.WriteString(t.result.Content)
+	result.WriteString("\n```")
+
+	return result.String()
+}
+
+// formatAgentResultForCopy formats agent tool results for clipboard.
+func (t *baseToolMessageItem) formatAgentResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var result strings.Builder
+
+	if t.result.Content != "" {
+		result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
+	}
+
+	return result.String()
+}
+
+// prettifyToolName returns a human-readable name for tool names.
+func prettifyToolName(name string) string {
+	switch name {
+	case agent.AgentToolName:
+		return "Agent"
+	case tools.BashToolName:
+		return "Bash"
+	case tools.JobOutputToolName:
+		return "Job: Output"
+	case tools.JobKillToolName:
+		return "Job: Kill"
+	case tools.DownloadToolName:
+		return "Download"
+	case tools.EditToolName:
+		return "Edit"
+	case tools.MultiEditToolName:
+		return "Multi-Edit"
+	case tools.FetchToolName:
+		return "Fetch"
+	case tools.AgenticFetchToolName:
+		return "Agentic Fetch"
+	case tools.WebFetchToolName:
+		return "Fetch"
+	case tools.WebSearchToolName:
+		return "Search"
+	case tools.GlobToolName:
+		return "Glob"
+	case tools.GrepToolName:
+		return "Grep"
+	case tools.LSToolName:
+		return "List"
+	case tools.SourcegraphToolName:
+		return "Sourcegraph"
+	case tools.TodosToolName:
+		return "To-Do"
+	case tools.ViewToolName:
+		return "View"
+	case tools.WriteToolName:
+		return "Write"
+	default:
+		return name
+	}
+}

internal/ui/chat/user.go 🔗

@@ -3,6 +3,7 @@ package chat
 import (
 	"strings"
 
+	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/attachments"
@@ -92,3 +93,12 @@ func (m *UserMessageItem) renderAttachments(width int) string {
 	}
 	return m.attachments.Render(attachments, false, width)
 }
+
+// HandleKeyEvent implements KeyEventHandler.
+func (m *UserMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
+	if key.String() == "c" {
+		text := m.message.Content().Text
+		return true, common.CopyToClipboard(text, "Message copied to clipboard")
+	}
+	return false, nil
+}

internal/ui/common/common.go 🔗

@@ -5,9 +5,12 @@ import (
 	"image"
 	"os"
 
+	tea "charm.land/bubbletea/v2"
+	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/uiutil"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -63,3 +66,25 @@ func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
 
 	return false, nil
 }
+
+// CopyToClipboard copies the given text to the clipboard using both OSC 52
+// (terminal escape sequence) and native clipboard for maximum compatibility.
+// Returns a command that reports success to the user with the given message.
+func CopyToClipboard(text, successMessage string) tea.Cmd {
+	return CopyToClipboardWithCallback(text, successMessage, nil)
+}
+
+// CopyToClipboardWithCallback copies text to clipboard and executes a callback
+// before showing the success message.
+// This is useful when you need to perform additional actions like clearing UI state.
+func CopyToClipboardWithCallback(text, successMessage string, callback tea.Cmd) tea.Cmd {
+	return tea.Sequence(
+		tea.SetClipboard(text),
+		func() tea.Msg {
+			_ = clipboard.WriteAll(text)
+			return nil
+		},
+		callback,
+		uiutil.ReportInfo(successMessage),
+	)
+}

internal/ui/model/chat.go 🔗

@@ -411,6 +411,16 @@ func (m *Chat) ToggleExpandedSelectedItem() {
 	}
 }
 
+// HandleKeyMsg handles key events for the chat component.
+func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
+	if m.list.Focused() {
+		if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
+			return handler.HandleKeyEvent(key)
+		}
+	}
+	return false, nil
+}
+
 // HandleMouseDown handles mouse down events for the chat component.
 func (m *Chat) HandleMouseDown(x, y int) bool {
 	if m.list.Len() == 0 {

internal/ui/model/ui.go 🔗

@@ -23,7 +23,6 @@ import (
 	"charm.land/bubbles/v2/textarea"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
-	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
@@ -1619,7 +1618,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				}
 				m.chat.SelectLast()
 			default:
-				handleGlobalKeys(msg)
+				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
+					cmds = append(cmds, cmd)
+				} else {
+					handleGlobalKeys(msg)
+				}
 			}
 		default:
 			handleGlobalKeys(msg)
@@ -2918,17 +2921,13 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
 
 func (m *UI) copyChatHighlight() tea.Cmd {
 	text := m.chat.HighlightContent()
-	return tea.Sequence(
-		tea.SetClipboard(text),
-		func() tea.Msg {
-			_ = clipboard.WriteAll(text)
-			return nil
-		},
+	return common.CopyToClipboardWithCallback(
+		text,
+		"Selected text copied to clipboard",
 		func() tea.Msg {
 			m.chat.ClearMouse()
 			return nil
 		},
-		uiutil.ReportInfo("Selected text copied to clipboard"),
 	)
 }