@@ -1,14 +1,19 @@
1package chat
1package chat
7 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
8 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
9 "charm.land/lipgloss/v2/tree"
12 "charm.land/lipgloss/v2/tree"
10 "github.com/charmbracelet/crush/internal/agent"
13 "github.com/charmbracelet/crush/internal/agent"
11 "github.com/charmbracelet/crush/internal/agent/tools"
14 "github.com/charmbracelet/crush/internal/agent/tools"
15+ "github.com/charmbracelet/crush/internal/diff"
16+ "github.com/charmbracelet/crush/internal/fsext"
12 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/ui/anim"
18 "github.com/charmbracelet/crush/internal/ui/anim"
14 "github.com/charmbracelet/crush/internal/ui/common"
19 "github.com/charmbracelet/crush/internal/ui/common"
@@ -413,6 +418,15 @@ func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) b
413 return true
418 return true
421+// HandleKeyEvent implements KeyEventHandler.
422+func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
423+ if key.String() == "c" {
424+ text := t.formatToolForCopy()
425+ return true, common.CopyToClipboard(text, "Tool content copied to clipboard")
416// pendingTool renders a tool that is still in progress with an animation.
430// pendingTool renders a tool that is still in progress with an animation.
417func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
431func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
418 icon := sty.Tool.IconPending.Render()
432 icon := sty.Tool.IconPending.Render()
@@ -807,3 +821,584 @@ func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, ex
808 return sty.Tool.Body.Render(strings.Join(out, "\n"))
822 return sty.Tool.Body.Render(strings.Join(out, "\n"))
825+// formatToolForCopy formats the tool call for clipboard copying.
826+func (t *baseToolMessageItem) formatToolForCopy() string {
829+ toolName := prettifyToolName(t.toolCall.Name)
830+ parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
832+ if t.toolCall.Input != "" {
833+ params := t.formatParametersForCopy()
835+ parts = append(parts, "### Parameters:")
836+ parts = append(parts, params)
840+ if t.result != nil && t.result.ToolCallID != "" {
841+ if t.result.IsError {
842+ parts = append(parts, "### Error:")
843+ parts = append(parts, t.result.Content)
845+ parts = append(parts, "### Result:")
846+ content := t.formatResultForCopy()
848+ parts = append(parts, content)
851+ } else if t.status == ToolStatusCanceled {
852+ parts = append(parts, "### Status:")
853+ parts = append(parts, "Cancelled")
855+ parts = append(parts, "### Status:")
856+ parts = append(parts, "Pending...")
859+ return strings.Join(parts, "\n\n")
862+// formatParametersForCopy formats tool parameters for clipboard copying.
863+func (t *baseToolMessageItem) formatParametersForCopy() string {
864+ switch t.toolCall.Name {
865+ case tools.BashToolName:
866+ var params tools.BashParams
867+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
868+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
869+ cmd = strings.ReplaceAll(cmd, "\t", " ")
870+ return fmt.Sprintf("**Command:** %s", cmd)
872+ case tools.ViewToolName:
873+ var params tools.ViewParams
874+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
876+ parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
877+ if params.Limit > 0 {
878+ parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
880+ if params.Offset > 0 {
881+ parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
883+ return strings.Join(parts, "\n")
885+ case tools.EditToolName:
886+ var params tools.EditParams
887+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
888+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
890+ case tools.MultiEditToolName:
891+ var params tools.MultiEditParams
892+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
894+ parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
895+ parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
896+ return strings.Join(parts, "\n")
898+ case tools.WriteToolName:
899+ var params tools.WriteParams
900+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
901+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
903+ case tools.FetchToolName:
904+ var params tools.FetchParams
905+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
907+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
908+ if params.Format != "" {
909+ parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
911+ if params.Timeout > 0 {
912+ parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
914+ return strings.Join(parts, "\n")
916+ case tools.AgenticFetchToolName:
917+ var params tools.AgenticFetchParams
918+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
920+ if params.URL != "" {
921+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
923+ if params.Prompt != "" {
924+ parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
926+ return strings.Join(parts, "\n")
928+ case tools.WebFetchToolName:
929+ var params tools.WebFetchParams
930+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
931+ return fmt.Sprintf("**URL:** %s", params.URL)
933+ case tools.GrepToolName:
934+ var params tools.GrepParams
935+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
937+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
938+ if params.Path != "" {
939+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
941+ if params.Include != "" {
942+ parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
944+ if params.LiteralText {
945+ parts = append(parts, "**Literal:** true")
947+ return strings.Join(parts, "\n")
949+ case tools.GlobToolName:
950+ var params tools.GlobParams
951+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
953+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
954+ if params.Path != "" {
955+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
957+ return strings.Join(parts, "\n")
959+ case tools.LSToolName:
960+ var params tools.LSParams
961+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
966+ return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
968+ case tools.DownloadToolName:
969+ var params tools.DownloadParams
970+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
972+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
973+ parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
974+ if params.Timeout > 0 {
975+ parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
977+ return strings.Join(parts, "\n")
979+ case tools.SourcegraphToolName:
980+ var params tools.SourcegraphParams
981+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
983+ parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
984+ if params.Count > 0 {
985+ parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
987+ if params.ContextWindow > 0 {
988+ parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
990+ return strings.Join(parts, "\n")
992+ case tools.DiagnosticsToolName:
993+ return "**Project:** diagnostics"
994+ case agent.AgentToolName:
995+ var params agent.AgentParams
996+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
997+ return fmt.Sprintf("**Task:**\n%s", params.Prompt)
1001+ var params map[string]any
1002+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil {
1004+ for key, value := range params {
1005+ displayKey := strings.ReplaceAll(key, "_", " ")
1006+ if len(displayKey) > 0 {
1007+ displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1009+ parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1011+ return strings.Join(parts, "\n")
1017+// formatResultForCopy formats tool results for clipboard copying.
1018+func (t *baseToolMessageItem) formatResultForCopy() string {
1019+ if t.result == nil {
1023+ if t.result.Data != "" {
1024+ if strings.HasPrefix(t.result.MIMEType, "image/") {
1025+ return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1027+ return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1030+ switch t.toolCall.Name {
1031+ case tools.BashToolName:
1032+ return t.formatBashResultForCopy()
1033+ case tools.ViewToolName:
1034+ return t.formatViewResultForCopy()
1035+ case tools.EditToolName:
1036+ return t.formatEditResultForCopy()
1037+ case tools.MultiEditToolName:
1038+ return t.formatMultiEditResultForCopy()
1039+ case tools.WriteToolName:
1040+ return t.formatWriteResultForCopy()
1041+ case tools.FetchToolName:
1042+ return t.formatFetchResultForCopy()
1043+ case tools.AgenticFetchToolName:
1044+ return t.formatAgenticFetchResultForCopy()
1045+ case tools.WebFetchToolName:
1046+ return t.formatWebFetchResultForCopy()
1047+ case agent.AgentToolName:
1048+ return t.formatAgentResultForCopy()
1049+ case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
1050+ return fmt.Sprintf("```\n%s\n```", t.result.Content)
1052+ return t.result.Content
1056+// formatBashResultForCopy formats bash tool results for clipboard.
1057+func (t *baseToolMessageItem) formatBashResultForCopy() string {
1058+ if t.result == nil {
1062+ var meta tools.BashResponseMetadata
1063+ if t.result.Metadata != "" {
1064+ json.Unmarshal([]byte(t.result.Metadata), &meta)
1067+ output := meta.Output
1068+ if output == "" && t.result.Content != tools.BashNoOutput {
1069+ output = t.result.Content
1076+ return fmt.Sprintf("```bash\n%s\n```", output)
1079+// formatViewResultForCopy formats view tool results for clipboard.
1080+func (t *baseToolMessageItem) formatViewResultForCopy() string {
1081+ if t.result == nil {
1085+ var meta tools.ViewResponseMetadata
1086+ if t.result.Metadata != "" {
1087+ json.Unmarshal([]byte(t.result.Metadata), &meta)
1090+ if meta.Content == "" {
1091+ return t.result.Content
1095+ if meta.FilePath != "" {
1096+ ext := strings.ToLower(filepath.Ext(meta.FilePath))
1100+ case ".js", ".mjs":
1101+ lang = "javascript"
1103+ lang = "typescript"
1112+ case ".cpp", ".cc", ".cxx":
1114+ case ".sh", ".bash":
1118+ case ".yaml", ".yml":
1131+ var result strings.Builder
1133+ result.WriteString(fmt.Sprintf("```%s\n", lang))
1135+ result.WriteString("```\n")
1137+ result.WriteString(meta.Content)
1138+ result.WriteString("\n```")
1140+ return result.String()
1143+// formatEditResultForCopy formats edit tool results for clipboard.
1144+func (t *baseToolMessageItem) formatEditResultForCopy() string {
1145+ if t.result == nil || t.result.Metadata == "" {
1146+ if t.result != nil {
1147+ return t.result.Content
1152+ var meta tools.EditResponseMetadata
1153+ if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1154+ return t.result.Content
1157+ var params tools.EditParams
1158+ json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1160+ var result strings.Builder
1162+ if meta.OldContent != "" || meta.NewContent != "" {
1163+ fileName := params.FilePath
1164+ if fileName != "" {
1165+ fileName = fsext.PrettyPath(fileName)
1167+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1169+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
1170+ result.WriteString("```diff\n")
1171+ result.WriteString(diffContent)
1172+ result.WriteString("\n```")
1175+ return result.String()
1178+// formatMultiEditResultForCopy formats multi-edit tool results for clipboard.
1179+func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
1180+ if t.result == nil || t.result.Metadata == "" {
1181+ if t.result != nil {
1182+ return t.result.Content
1187+ var meta tools.MultiEditResponseMetadata
1188+ if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1189+ return t.result.Content
1192+ var params tools.MultiEditParams
1193+ json.Unmarshal([]byte(t.toolCall.Input), ¶ms)
1195+ var result strings.Builder
1196+ if meta.OldContent != "" || meta.NewContent != "" {
1197+ fileName := params.FilePath
1198+ if fileName != "" {
1199+ fileName = fsext.PrettyPath(fileName)
1201+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1203+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
1204+ result.WriteString("```diff\n")
1205+ result.WriteString(diffContent)
1206+ result.WriteString("\n```")
1209+ return result.String()
1212+// formatWriteResultForCopy formats write tool results for clipboard.
1213+func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1214+ if t.result == nil {
1218+ var params tools.WriteParams
1219+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1220+ return t.result.Content
1224+ if params.FilePath != "" {
1225+ ext := strings.ToLower(filepath.Ext(params.FilePath))
1229+ case ".js", ".mjs":
1230+ lang = "javascript"
1232+ lang = "typescript"
1241+ case ".cpp", ".cc", ".cxx":
1243+ case ".sh", ".bash":
1247+ case ".yaml", ".yml":
1260+ var result strings.Builder
1261+ result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
1263+ result.WriteString(fmt.Sprintf("```%s\n", lang))
1265+ result.WriteString("```\n")
1267+ result.WriteString(params.Content)
1268+ result.WriteString("\n```")
1270+ return result.String()
1273+// formatFetchResultForCopy formats fetch tool results for clipboard.
1274+func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1275+ if t.result == nil {
1279+ var params tools.FetchParams
1280+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1281+ return t.result.Content
1284+ var result strings.Builder
1285+ if params.URL != "" {
1286+ result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
1288+ if params.Format != "" {
1289+ result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
1291+ if params.Timeout > 0 {
1292+ result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
1294+ result.WriteString("\n")
1296+ result.WriteString(t.result.Content)
1298+ return result.String()
1301+// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1302+func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1303+ if t.result == nil {
1307+ var params tools.AgenticFetchParams
1308+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1309+ return t.result.Content
1312+ var result strings.Builder
1313+ if params.URL != "" {
1314+ result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
1316+ if params.Prompt != "" {
1317+ result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
1320+ result.WriteString("```markdown\n")
1321+ result.WriteString(t.result.Content)
1322+ result.WriteString("\n```")
1324+ return result.String()
1327+// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1328+func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1329+ if t.result == nil {
1333+ var params tools.WebFetchParams
1334+ if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil {
1335+ return t.result.Content
1338+ var result strings.Builder
1339+ result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
1340+ result.WriteString("```markdown\n")
1341+ result.WriteString(t.result.Content)
1342+ result.WriteString("\n```")
1344+ return result.String()
1347+// formatAgentResultForCopy formats agent tool results for clipboard.
1348+func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1349+ if t.result == nil {
1353+ var result strings.Builder
1355+ if t.result.Content != "" {
1356+ result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
1359+ return result.String()
1362+// prettifyToolName returns a human-readable name for tool names.
1363+func prettifyToolName(name string) string {
1365+ case agent.AgentToolName:
1367+ case tools.BashToolName:
1369+ case tools.JobOutputToolName:
1370+ return "Job: Output"
1371+ case tools.JobKillToolName:
1373+ case tools.DownloadToolName:
1375+ case tools.EditToolName:
1377+ case tools.MultiEditToolName:
1378+ return "Multi-Edit"
1379+ case tools.FetchToolName:
1381+ case tools.AgenticFetchToolName:
1382+ return "Agentic Fetch"
1383+ case tools.WebFetchToolName:
1385+ case tools.WebSearchToolName:
1387+ case tools.GlobToolName:
1389+ case tools.GrepToolName:
1391+ case tools.LSToolName:
1393+ case tools.SourcegraphToolName:
1394+ return "Sourcegraph"
1395+ case tools.TodosToolName:
1397+ case tools.ViewToolName:
1399+ case tools.WriteToolName: