From 88d10d13367eb0722898196b78a422cf98b6c21e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 23 Jan 2026 15:06:38 -0500 Subject: [PATCH] feat(ui): add keybinding to copy chat message content to clipboard (#1947) * 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 --- 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(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 7ff53264ead1b2e264cec981ef6aa5cb541247d3..66459a86fd1b457907d25ee0dbd36c69b26dbd34 100644 --- a/internal/ui/chat/assistant.go +++ b/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 +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 6be07e4759020c9aed25f042668d89e96584cccc..45314347187e7018445d30753ffd05d24dbc716a 100644 --- a/internal/ui/chat/messages.go +++ b/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 { diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index ffe8e680159dc7c5a5ee177e06f7aa678a945641..a91ca9b28355674a6aaf433d33b83ad838c8d446 100644 --- a/internal/ui/chat/tools.go +++ b/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), ¶ms) == 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), ¶ms) == 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), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.MultiEditToolName: + var params tools.MultiEditParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == 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), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.FetchToolName: + var params tools.FetchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == 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), ¶ms) == 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), ¶ms) == nil { + return fmt.Sprintf("**URL:** %s", params.URL) + } + case tools.GrepToolName: + var params tools.GrepParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == 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), ¶ms) == 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), ¶ms) == 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), ¶ms) == 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), ¶ms) == 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), ¶ms) == nil { + return fmt.Sprintf("**Task:**\n%s", params.Prompt) + } + } + + var params map[string]any + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == 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), ¶ms) + + 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), ¶ms) + + 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), ¶ms) != 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), ¶ms) != 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), ¶ms) != 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), ¶ms) != 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 + } +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 7383c841ae3e274bdfea8dcc4db37e1259dbbb21..3482723bfdff519afeacb6bf7a553009c42cd64f 100644 --- a/internal/ui/chat/user.go +++ b/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 +} diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go index 21ab903c388adaa1f626bef46f09c3829f927086..0150b6e4b84085637178009ec7859ae2f28aaf93 100644 --- a/internal/ui/common/common.go +++ b/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), + ) +} diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index f3388085d1808d984ed4b90bdeaeb58d71cb2463..d009a261580eaed209c1fc15966f50f4a8b3e62d 100644 --- a/internal/ui/model/chat.go +++ b/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 { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 8c02191b34f413c566575138bc3e1ead9bae90c4..668b487567f701c8729a717c147ce39011b3edb5 100644 --- a/internal/ui/model/ui.go +++ b/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"), ) }