chore: add copy

Kujtim Hoxha created

Change summary

go.mod                                            |   2 
internal/tui/components/chat/messages/messages.go |  11 
internal/tui/components/chat/messages/tool.go     | 464 +++++++++++++++++
internal/tui/exp/list/filterable.go               |   2 
internal/tui/exp/list/list.go                     |  12 
internal/tui/page/chat/chat.go                    |   4 
6 files changed, 493 insertions(+), 2 deletions(-)

Detailed changes

go.mod 🔗

@@ -8,6 +8,7 @@ require (
 	github.com/PuerkitoBio/goquery v1.9.2
 	github.com/alecthomas/chroma/v2 v2.15.0
 	github.com/anthropics/anthropic-sdk-go v1.6.2
+	github.com/atotto/clipboard v0.1.4
 	github.com/aymanbagabas/go-udiff v0.3.1
 	github.com/bmatcuk/doublestar/v4 v4.9.0
 	github.com/charlievieth/fastwalk v1.0.11
@@ -55,7 +56,6 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
-	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
 	github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect

internal/tui/components/chat/messages/messages.go 🔗

@@ -6,6 +6,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/viewport"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -13,6 +14,7 @@ import (
 	"github.com/charmbracelet/x/ansi"
 	"github.com/google/uuid"
 
+	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -23,6 +25,8 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
+var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
+
 // MessageCmp defines the interface for message components in the chat interface.
 // It combines standard UI model interfaces with message-specific functionality.
 type MessageCmp interface {
@@ -94,6 +98,13 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.anim = u.(anim.Anim)
 			return m, 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, nil
 }

internal/tui/components/chat/messages/tool.go 🔗

@@ -1,9 +1,19 @@
 package messages
 
 import (
+	"encoding/json"
 	"fmt"
+	"path/filepath"
+	"strings"
+	"time"
 
+	"github.com/atotto/clipboard"
+	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/llm/agent"
+	"github.com/charmbracelet/crush/internal/llm/tools"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -154,6 +164,10 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 		}
 		return m, tea.Batch(cmds...)
+	case tea.KeyPressMsg:
+		if key.Matches(msg, copyKey) {
+			return m, m.copyTool()
+		}
 	}
 	return m, nil
 }
@@ -182,6 +196,456 @@ func (m *toolCallCmp) SetCancelled() {
 	m.cancelled = true
 }
 
+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 nil
+}
+
+func (m *toolCallCmp) formatToolForCopy() string {
+	var parts []string
+
+	toolName := prettifyToolName(m.call.Name)
+	parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
+
+	if m.call.Input != "" {
+		params := m.formatParametersForCopy()
+		if params != "" {
+			parts = append(parts, "### Parameters:")
+			parts = append(parts, params)
+		}
+	}
+
+	if m.result.ToolCallID != "" {
+		if m.result.IsError {
+			parts = append(parts, "### Error:")
+			parts = append(parts, m.result.Content)
+		} else {
+			parts = append(parts, "### Result:")
+			content := m.formatResultForCopy()
+			if content != "" {
+				parts = append(parts, content)
+			}
+		}
+	} else if m.cancelled {
+		parts = append(parts, "### Status:")
+		parts = append(parts, "Cancelled")
+	} else {
+		parts = append(parts, "### Status:")
+		parts = append(parts, "Pending...")
+	}
+
+	return strings.Join(parts, "\n\n")
+}
+
+func (m *toolCallCmp) formatParametersForCopy() string {
+	switch m.call.Name {
+	case tools.BashToolName:
+		var params tools.BashParams
+		if json.Unmarshal([]byte(m.call.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(m.call.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(m.call.Input), &params) == nil {
+			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
+		}
+	case tools.MultiEditToolName:
+		var params tools.MultiEditParams
+		if json.Unmarshal([]byte(m.call.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(m.call.Input), &params) == nil {
+			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
+		}
+	case tools.FetchToolName:
+		var params tools.FetchParams
+		if json.Unmarshal([]byte(m.call.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:** %s", (time.Duration(params.Timeout)*time.Second).String()))
+			}
+			return strings.Join(parts, "\n")
+		}
+	case tools.GrepToolName:
+		var params tools.GrepParams
+		if json.Unmarshal([]byte(m.call.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(m.call.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(m.call.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(m.call.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(m.call.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(m.call.Input), &params) == nil {
+			return fmt.Sprintf("**Task:**\n%s", params.Prompt)
+		}
+	}
+
+	var params map[string]any
+	if json.Unmarshal([]byte(m.call.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 ""
+}
+
+func (m *toolCallCmp) formatResultForCopy() string {
+	switch m.call.Name {
+	case tools.BashToolName:
+		return m.formatBashResultForCopy()
+	case tools.ViewToolName:
+		return m.formatViewResultForCopy()
+	case tools.EditToolName:
+		return m.formatEditResultForCopy()
+	case tools.MultiEditToolName:
+		return m.formatMultiEditResultForCopy()
+	case tools.WriteToolName:
+		return m.formatWriteResultForCopy()
+	case tools.FetchToolName:
+		return m.formatFetchResultForCopy()
+	case agent.AgentToolName:
+		return m.formatAgentResultForCopy()
+	case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName:
+		return fmt.Sprintf("```\n%s\n```", m.result.Content)
+	default:
+		return m.result.Content
+	}
+}
+
+func (m *toolCallCmp) formatBashResultForCopy() string {
+	var meta tools.BashResponseMetadata
+	if m.result.Metadata != "" {
+		json.Unmarshal([]byte(m.result.Metadata), &meta)
+	}
+
+	output := meta.Output
+	if output == "" && m.result.Content != tools.BashNoOutput {
+		output = m.result.Content
+	}
+
+	if output == "" {
+		return ""
+	}
+
+	return fmt.Sprintf("```bash\n%s\n```", output)
+}
+
+func (m *toolCallCmp) formatViewResultForCopy() string {
+	var meta tools.ViewResponseMetadata
+	if m.result.Metadata != "" {
+		json.Unmarshal([]byte(m.result.Metadata), &meta)
+	}
+
+	if meta.Content == "" {
+		return m.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()
+}
+
+func (m *toolCallCmp) formatEditResultForCopy() string {
+	var meta tools.EditResponseMetadata
+	if m.result.Metadata == "" {
+		return m.result.Content
+	}
+
+	if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
+		return m.result.Content
+	}
+
+	var params tools.EditParams
+	json.Unmarshal([]byte(m.call.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()
+}
+
+func (m *toolCallCmp) formatMultiEditResultForCopy() string {
+	var meta tools.MultiEditResponseMetadata
+	if m.result.Metadata == "" {
+		return m.result.Content
+	}
+
+	if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
+		return m.result.Content
+	}
+
+	var params tools.MultiEditParams
+	json.Unmarshal([]byte(m.call.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()
+}
+
+func (m *toolCallCmp) formatWriteResultForCopy() string {
+	var params tools.WriteParams
+	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
+		return m.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()
+}
+
+func (m *toolCallCmp) formatFetchResultForCopy() string {
+	var params tools.FetchParams
+	if json.Unmarshal([]byte(m.call.Input), &params) != nil {
+		return m.result.Content
+	}
+
+	var result strings.Builder
+	if params.URL != "" {
+		result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
+	}
+
+	switch params.Format {
+	case "html":
+		result.WriteString("```html\n")
+	case "text":
+		result.WriteString("```\n")
+	default: // markdown
+		result.WriteString("```markdown\n")
+	}
+	result.WriteString(m.result.Content)
+	result.WriteString("\n```")
+
+	return result.String()
+}
+
+func (m *toolCallCmp) formatAgentResultForCopy() string {
+	var result strings.Builder
+
+	if len(m.nestedToolCalls) > 0 {
+		result.WriteString("### Nested Tool Calls:\n")
+		for i, nestedCall := range m.nestedToolCalls {
+			nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
+			indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n  ")
+			result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
+			if i < len(m.nestedToolCalls)-1 {
+				result.WriteString("\n")
+			}
+		}
+
+		if m.result.Content != "" {
+			result.WriteString("\n### Final Result:\n")
+		}
+	}
+
+	if m.result.Content != "" {
+		result.WriteString(fmt.Sprintf("```\n%s\n```", m.result.Content))
+	}
+
+	return result.String()
+}
+
 // SetToolCall updates the tool call data and stops spinning if finished
 func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
 	m.call = call

internal/tui/exp/list/filterable.go 🔗

@@ -94,7 +94,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
 	for _, opt := range opts {
 		opt(f.filterableOptions)
 	}
-	f.list = New[T](items, f.listOptions...).(*list[T])
+	f.list = New(items, f.listOptions...).(*list[T])
 
 	f.updateKeyMaps()
 	f.items = f.list.items.Slice()

internal/tui/exp/list/list.go 🔗

@@ -235,6 +235,18 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			case key.Matches(msg, l.keyMap.Home):
 				return l, l.GoToTop()
 			}
+			s := l.SelectedItem()
+			if s == nil {
+				return l, nil
+			}
+			item := *s
+			var cmds []tea.Cmd
+			updated, cmd := item.Update(msg)
+			cmds = append(cmds, cmd)
+			if u, ok := updated.(T); ok {
+				cmds = append(cmds, l.UpdateItem(u.ID(), u))
+			}
+			return l, tea.Batch(cmds...)
 		}
 	}
 	return l, nil

internal/tui/page/chat/chat.go 🔗

@@ -825,6 +825,10 @@ func (p *chatPage) Help() help.KeyMap {
 					key.WithKeys("up", "down"),
 					key.WithHelp("↑↓", "scroll"),
 				),
+				key.NewBinding(
+					key.WithKeys("c", "y"),
+					key.WithHelp("c/y", "copy"),
+				),
 			)
 			fullList = append(fullList,
 				[]key.Binding{