@@ -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
+ }
+}