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"), ) }