From 683bcae7453a346716e9adf4da01fa04b7109328 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 25 Jul 2025 18:51:48 +0200 Subject: [PATCH] chore: add copy --- go.mod | 2 +- .../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(-) diff --git a/go.mod b/go.mod index b4779ad998320aad820743bddbcad799e544e24c..5ef73a62c714c0f6243696ee50b251d87b3a350a 100644 --- a/go.mod +++ b/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 diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 9f70691aa9843b8d823b26be247636b31212d2eb..7b6cc058ea0746639092f244ccf4b60d06101aec 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 } diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 51375c1b1bb11956069a733ffa509aae112eb073..e4b578275a8d208925057aac2e5c0028fb8fc8c7 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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), ¶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(m.call.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(m.call.Input), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.MultiEditToolName: + var params tools.MultiEditParams + if json.Unmarshal([]byte(m.call.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(m.call.Input), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } + case tools.FetchToolName: + var params tools.FetchParams + if json.Unmarshal([]byte(m.call.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:** %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), ¶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(m.call.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(m.call.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(m.call.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(m.call.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(m.call.Input), ¶ms) == nil { + return fmt.Sprintf("**Task:**\n%s", params.Prompt) + } + } + + var params map[string]any + if json.Unmarshal([]byte(m.call.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 "" +} + +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), ¶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() +} + +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), ¶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() +} + +func (m *toolCallCmp) formatWriteResultForCopy() string { + var params tools.WriteParams + if json.Unmarshal([]byte(m.call.Input), ¶ms) != 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), ¶ms) != 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 diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 909bcf8a42c728aea398bdc16794b63d7d6e725d..f806341d77e4f01063eda61a8a46227ba9bb93a4 100644 --- a/internal/tui/exp/list/filterable.go +++ b/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() diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 3af90d405382ba5df207774c4f2fba109717034a..68423eb4b8bb0761201c0540bad680b8f1710907 100644 --- a/internal/tui/exp/list/list.go +++ b/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 diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 253f60ee5c733045bae4ee272d64f4bf8c18a2bb..81e9e11ce5bd7c609adbf24847b11114ffbaffed 100644 --- a/internal/tui/page/chat/chat.go +++ b/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{