on main help sign in
chore: add copy
commit683bcae7453a346716e9adf4da01fa04b7109328 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 25 Jul 2025, 18:51 (1 year ago) parent39f0772
6 files changed+493−2
go.mod+1−1 internal/tui/components/chat/messages/messages.go+11 internal/tui/components/chat/messages/tool.go+464 internal/tui/exp/list/filterable.go+1−1 internal/tui/exp/list/list.go+12 internal/tui/page/chat/chat.go+4
go.mod+1−1
@@ -8,6 +8,7 @@ require (
88 github.com/PuerkitoBio/goquery v1.9.2
99 github.com/alecthomas/chroma/v2 v2.15.0
1010 github.com/anthropics/anthropic-sdk-go v1.6.2
11+ github.com/atotto/clipboard v0.1.4
1112 github.com/aymanbagabas/go-udiff v0.3.1
1213 github.com/bmatcuk/doublestar/v4 v4.9.0
1314 github.com/charlievieth/fastwalk v1.0.11
@@ -55,7 +56,6 @@ require (
5556 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
5657 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
5758 github.com/andybalholm/cascadia v1.3.2 // indirect
58− github.com/atotto/clipboard v0.1.4 // indirect
5959 github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
6060 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
6161 github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
internal/tui/components/chat/messages/messages.go+11
@@ -6,6 +6,7 @@ import (
66 "strings"
77 "time"
88
9+ "github.com/charmbracelet/bubbles/v2/key"
910 "github.com/charmbracelet/bubbles/v2/viewport"
1011 tea "github.com/charmbracelet/bubbletea/v2"
1112 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -13,6 +14,7 @@ import (
1314 "github.com/charmbracelet/x/ansi"
1415 "github.com/google/uuid"
1516
17+ "github.com/atotto/clipboard"
1618 "github.com/charmbracelet/crush/internal/config"
1719 "github.com/charmbracelet/crush/internal/message"
1820 "github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -23,6 +25,8 @@ import (
2325 "github.com/charmbracelet/crush/internal/tui/util"
2426)
2527
28+var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
29+
2630// MessageCmp defines the interface for message components in the chat interface.
2731// It combines standard UI model interfaces with message-specific functionality.
2832type MessageCmp interface {
@@ -94,6 +98,13 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9498 m.anim = u.(anim.Anim)
9599 return m, cmd
96100 }
101+ case tea.KeyPressMsg:
102+ if key.Matches(msg, copyKey) {
103+ err := clipboard.WriteAll(m.message.Content().Text)
104+ if err != nil {
105+ return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err))
106+ }
107+ }
97108 }
98109 return m, nil
99110}
internal/tui/components/chat/messages/tool.go+464
@@ -1,9 +1,19 @@
11package messages
22
33import (
4+ "encoding/json"
45 "fmt"
6+ "path/filepath"
7+ "strings"
8+ "time"
59
10+ "github.com/atotto/clipboard"
11+ "github.com/charmbracelet/bubbles/v2/key"
612 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/diff"
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/crush/internal/llm/agent"
16+ "github.com/charmbracelet/crush/internal/llm/tools"
717 "github.com/charmbracelet/crush/internal/message"
818 "github.com/charmbracelet/crush/internal/permission"
919 "github.com/charmbracelet/crush/internal/tui/components/anim"
@@ -154,6 +164,10 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
154164 cmds = append(cmds, cmd)
155165 }
156166 return m, tea.Batch(cmds...)
167+ case tea.KeyPressMsg:
168+ if key.Matches(msg, copyKey) {
169+ return m, m.copyTool()
170+ }
157171 }
158172 return m, nil
159173}
@@ -182,6 +196,456 @@ func (m *toolCallCmp) SetCancelled() {
182196 m.cancelled = true
183197}
184198
199+func (m *toolCallCmp) copyTool() tea.Cmd {
200+ content := m.formatToolForCopy()
201+ err := clipboard.WriteAll(content)
202+ if err != nil {
203+ return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err))
204+ }
205+ return nil
206+}
207+
208+func (m *toolCallCmp) formatToolForCopy() string {
209+ var parts []string
210+
211+ toolName := prettifyToolName(m.call.Name)
212+ parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
213+
214+ if m.call.Input != "" {
215+ params := m.formatParametersForCopy()
216+ if params != "" {
217+ parts = append(parts, "### Parameters:")
218+ parts = append(parts, params)
219+ }
220+ }
221+
222+ if m.result.ToolCallID != "" {
223+ if m.result.IsError {
224+ parts = append(parts, "### Error:")
225+ parts = append(parts, m.result.Content)
226+ } else {
227+ parts = append(parts, "### Result:")
228+ content := m.formatResultForCopy()
229+ if content != "" {
230+ parts = append(parts, content)
231+ }
232+ }
233+ } else if m.cancelled {
234+ parts = append(parts, "### Status:")
235+ parts = append(parts, "Cancelled")
236+ } else {
237+ parts = append(parts, "### Status:")
238+ parts = append(parts, "Pending...")
239+ }
240+
241+ return strings.Join(parts, "\n\n")
242+}
243+
244+func (m *toolCallCmp) formatParametersForCopy() string {
245+ switch m.call.Name {
246+ case tools.BashToolName:
247+ var params tools.BashParams
248+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
249+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
250+ cmd = strings.ReplaceAll(cmd, "\t", " ")
251+ return fmt.Sprintf("**Command:** %s", cmd)
252+ }
253+ case tools.ViewToolName:
254+ var params tools.ViewParams
255+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
256+ var parts []string
257+ parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
258+ if params.Limit > 0 {
259+ parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
260+ }
261+ if params.Offset > 0 {
262+ parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
263+ }
264+ return strings.Join(parts, "\n")
265+ }
266+ case tools.EditToolName:
267+ var params tools.EditParams
268+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
269+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
270+ }
271+ case tools.MultiEditToolName:
272+ var params tools.MultiEditParams
273+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
274+ var parts []string
275+ parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
276+ parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
277+ return strings.Join(parts, "\n")
278+ }
279+ case tools.WriteToolName:
280+ var params tools.WriteParams
281+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
282+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
283+ }
284+ case tools.FetchToolName:
285+ var params tools.FetchParams
286+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
287+ var parts []string
288+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
289+ if params.Format != "" {
290+ parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
291+ }
292+ if params.Timeout > 0 {
293+ parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
294+ }
295+ return strings.Join(parts, "\n")
296+ }
297+ case tools.GrepToolName:
298+ var params tools.GrepParams
299+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
300+ var parts []string
301+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
302+ if params.Path != "" {
303+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
304+ }
305+ if params.Include != "" {
306+ parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
307+ }
308+ if params.LiteralText {
309+ parts = append(parts, "**Literal:** true")
310+ }
311+ return strings.Join(parts, "\n")
312+ }
313+ case tools.GlobToolName:
314+ var params tools.GlobParams
315+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
316+ var parts []string
317+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
318+ if params.Path != "" {
319+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
320+ }
321+ return strings.Join(parts, "\n")
322+ }
323+ case tools.LSToolName:
324+ var params tools.LSParams
325+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
326+ path := params.Path
327+ if path == "" {
328+ path = "."
329+ }
330+ return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
331+ }
332+ case tools.DownloadToolName:
333+ var params tools.DownloadParams
334+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
335+ var parts []string
336+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
337+ parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
338+ if params.Timeout > 0 {
339+ parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
340+ }
341+ return strings.Join(parts, "\n")
342+ }
343+ case tools.SourcegraphToolName:
344+ var params tools.SourcegraphParams
345+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
346+ var parts []string
347+ parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
348+ if params.Count > 0 {
349+ parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
350+ }
351+ if params.ContextWindow > 0 {
352+ parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
353+ }
354+ return strings.Join(parts, "\n")
355+ }
356+ case tools.DiagnosticsToolName:
357+ return "**Project:** diagnostics"
358+ case agent.AgentToolName:
359+ var params agent.AgentParams
360+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
361+ return fmt.Sprintf("**Task:**\n%s", params.Prompt)
362+ }
363+ }
364+
365+ var params map[string]any
366+ if json.Unmarshal([]byte(m.call.Input), &params) == nil {
367+ var parts []string
368+ for key, value := range params {
369+ displayKey := strings.ReplaceAll(key, "_", " ")
370+ if len(displayKey) > 0 {
371+ displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
372+ }
373+ parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
374+ }
375+ return strings.Join(parts, "\n")
376+ }
377+
378+ return ""
379+}
380+
381+func (m *toolCallCmp) formatResultForCopy() string {
382+ switch m.call.Name {
383+ case tools.BashToolName:
384+ return m.formatBashResultForCopy()
385+ case tools.ViewToolName:
386+ return m.formatViewResultForCopy()
387+ case tools.EditToolName:
388+ return m.formatEditResultForCopy()
389+ case tools.MultiEditToolName:
390+ return m.formatMultiEditResultForCopy()
391+ case tools.WriteToolName:
392+ return m.formatWriteResultForCopy()
393+ case tools.FetchToolName:
394+ return m.formatFetchResultForCopy()
395+ case agent.AgentToolName:
396+ return m.formatAgentResultForCopy()
397+ case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName:
398+ return fmt.Sprintf("```\n%s\n```", m.result.Content)
399+ default:
400+ return m.result.Content
401+ }
402+}
403+
404+func (m *toolCallCmp) formatBashResultForCopy() string {
405+ var meta tools.BashResponseMetadata
406+ if m.result.Metadata != "" {
407+ json.Unmarshal([]byte(m.result.Metadata), &meta)
408+ }
409+
410+ output := meta.Output
411+ if output == "" && m.result.Content != tools.BashNoOutput {
412+ output = m.result.Content
413+ }
414+
415+ if output == "" {
416+ return ""
417+ }
418+
419+ return fmt.Sprintf("```bash\n%s\n```", output)
420+}
421+
422+func (m *toolCallCmp) formatViewResultForCopy() string {
423+ var meta tools.ViewResponseMetadata
424+ if m.result.Metadata != "" {
425+ json.Unmarshal([]byte(m.result.Metadata), &meta)
426+ }
427+
428+ if meta.Content == "" {
429+ return m.result.Content
430+ }
431+
432+ lang := ""
433+ if meta.FilePath != "" {
434+ ext := strings.ToLower(filepath.Ext(meta.FilePath))
435+ switch ext {
436+ case ".go":
437+ lang = "go"
438+ case ".js", ".mjs":
439+ lang = "javascript"
440+ case ".ts":
441+ lang = "typescript"
442+ case ".py":
443+ lang = "python"
444+ case ".rs":
445+ lang = "rust"
446+ case ".java":
447+ lang = "java"
448+ case ".c":
449+ lang = "c"
450+ case ".cpp", ".cc", ".cxx":
451+ lang = "cpp"
452+ case ".sh", ".bash":
453+ lang = "bash"
454+ case ".json":
455+ lang = "json"
456+ case ".yaml", ".yml":
457+ lang = "yaml"
458+ case ".xml":
459+ lang = "xml"
460+ case ".html":
461+ lang = "html"
462+ case ".css":
463+ lang = "css"
464+ case ".md":
465+ lang = "markdown"
466+ }
467+ }
468+
469+ var result strings.Builder
470+ if lang != "" {
471+ result.WriteString(fmt.Sprintf("```%s\n", lang))
472+ } else {
473+ result.WriteString("```\n")
474+ }
475+ result.WriteString(meta.Content)
476+ result.WriteString("\n```")
477+
478+ return result.String()
479+}
480+
481+func (m *toolCallCmp) formatEditResultForCopy() string {
482+ var meta tools.EditResponseMetadata
483+ if m.result.Metadata == "" {
484+ return m.result.Content
485+ }
486+
487+ if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
488+ return m.result.Content
489+ }
490+
491+ var params tools.EditParams
492+ json.Unmarshal([]byte(m.call.Input), &params)
493+
494+ var result strings.Builder
495+
496+ if meta.OldContent != "" || meta.NewContent != "" {
497+ fileName := params.FilePath
498+ if fileName != "" {
499+ fileName = fsext.PrettyPath(fileName)
500+ }
501+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
502+
503+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
504+ result.WriteString("```diff\n")
505+ result.WriteString(diffContent)
506+ result.WriteString("\n```")
507+ }
508+
509+ return result.String()
510+}
511+
512+func (m *toolCallCmp) formatMultiEditResultForCopy() string {
513+ var meta tools.MultiEditResponseMetadata
514+ if m.result.Metadata == "" {
515+ return m.result.Content
516+ }
517+
518+ if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
519+ return m.result.Content
520+ }
521+
522+ var params tools.MultiEditParams
523+ json.Unmarshal([]byte(m.call.Input), &params)
524+
525+ var result strings.Builder
526+ if meta.OldContent != "" || meta.NewContent != "" {
527+ fileName := params.FilePath
528+ if fileName != "" {
529+ fileName = fsext.PrettyPath(fileName)
530+ }
531+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
532+
533+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
534+ result.WriteString("```diff\n")
535+ result.WriteString(diffContent)
536+ result.WriteString("\n```")
537+ }
538+
539+ return result.String()
540+}
541+
542+func (m *toolCallCmp) formatWriteResultForCopy() string {
543+ var params tools.WriteParams
544+ if json.Unmarshal([]byte(m.call.Input), &params) != nil {
545+ return m.result.Content
546+ }
547+
548+ lang := ""
549+ if params.FilePath != "" {
550+ ext := strings.ToLower(filepath.Ext(params.FilePath))
551+ switch ext {
552+ case ".go":
553+ lang = "go"
554+ case ".js", ".mjs":
555+ lang = "javascript"
556+ case ".ts":
557+ lang = "typescript"
558+ case ".py":
559+ lang = "python"
560+ case ".rs":
561+ lang = "rust"
562+ case ".java":
563+ lang = "java"
564+ case ".c":
565+ lang = "c"
566+ case ".cpp", ".cc", ".cxx":
567+ lang = "cpp"
568+ case ".sh", ".bash":
569+ lang = "bash"
570+ case ".json":
571+ lang = "json"
572+ case ".yaml", ".yml":
573+ lang = "yaml"
574+ case ".xml":
575+ lang = "xml"
576+ case ".html":
577+ lang = "html"
578+ case ".css":
579+ lang = "css"
580+ case ".md":
581+ lang = "markdown"
582+ }
583+ }
584+
585+ var result strings.Builder
586+ result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
587+ if lang != "" {
588+ result.WriteString(fmt.Sprintf("```%s\n", lang))
589+ } else {
590+ result.WriteString("```\n")
591+ }
592+ result.WriteString(params.Content)
593+ result.WriteString("\n```")
594+
595+ return result.String()
596+}
597+
598+func (m *toolCallCmp) formatFetchResultForCopy() string {
599+ var params tools.FetchParams
600+ if json.Unmarshal([]byte(m.call.Input), &params) != nil {
601+ return m.result.Content
602+ }
603+
604+ var result strings.Builder
605+ if params.URL != "" {
606+ result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
607+ }
608+
609+ switch params.Format {
610+ case "html":
611+ result.WriteString("```html\n")
612+ case "text":
613+ result.WriteString("```\n")
614+ default: // markdown
615+ result.WriteString("```markdown\n")
616+ }
617+ result.WriteString(m.result.Content)
618+ result.WriteString("\n```")
619+
620+ return result.String()
621+}
622+
623+func (m *toolCallCmp) formatAgentResultForCopy() string {
624+ var result strings.Builder
625+
626+ if len(m.nestedToolCalls) > 0 {
627+ result.WriteString("### Nested Tool Calls:\n")
628+ for i, nestedCall := range m.nestedToolCalls {
629+ nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
630+ indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n ")
631+ result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
632+ if i < len(m.nestedToolCalls)-1 {
633+ result.WriteString("\n")
634+ }
635+ }
636+
637+ if m.result.Content != "" {
638+ result.WriteString("\n### Final Result:\n")
639+ }
640+ }
641+
642+ if m.result.Content != "" {
643+ result.WriteString(fmt.Sprintf("```\n%s\n```", m.result.Content))
644+ }
645+
646+ return result.String()
647+}
648+
185649// SetToolCall updates the tool call data and stops spinning if finished
186650func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
187651 m.call = call
internal/tui/exp/list/filterable.go+1−1
@@ -94,7 +94,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
9494 for _, opt := range opts {
9595 opt(f.filterableOptions)
9696 }
97− f.list = New[T](items, f.listOptions...).(*list[T])
97+ f.list = New(items, f.listOptions...).(*list[T])
9898
9999 f.updateKeyMaps()
100100 f.items = f.list.items.Slice()
internal/tui/exp/list/list.go+12
@@ -235,6 +235,18 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
235235 case key.Matches(msg, l.keyMap.Home):
236236 return l, l.GoToTop()
237237 }
238+ s := l.SelectedItem()
239+ if s == nil {
240+ return l, nil
241+ }
242+ item := *s
243+ var cmds []tea.Cmd
244+ updated, cmd := item.Update(msg)
245+ cmds = append(cmds, cmd)
246+ if u, ok := updated.(T); ok {
247+ cmds = append(cmds, l.UpdateItem(u.ID(), u))
248+ }
249+ return l, tea.Batch(cmds...)
238250 }
239251 }
240252 return l, nil
internal/tui/page/chat/chat.go+4
@@ -825,6 +825,10 @@ func (p *chatPage) Help() help.KeyMap {
825825 key.WithKeys("up", "down"),
826826 key.WithHelp("↑↓", "scroll"),
827827 ),
828+ key.NewBinding(
829+ key.WithKeys("c", "y"),
830+ key.WithHelp("c/y", "copy"),
831+ ),
828832 )
829833 fullList = append(fullList,
830834 []key.Binding{