on main help sign in
feat(ui): add keybinding to copy chat message content to clipboard (#1947)
88d10d1Ayman Bagabas
internal/ui/chat/assistant.go+9
@@ -255,3 +255,12 @@ func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int)
255 }
255 }
256 return false
256 return false
257}
257}
258+
259+// HandleKeyEvent implements KeyEventHandler.
260+func (a *AssistantMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
261+ if key.String() == "c" {
262+ text := a.message.Content().Text
263+ return true, common.CopyToClipboard(text, "Message copied to clipboard")
264+ }
265+ return false, nil
266+}
internal/ui/chat/messages.go+5
@@ -41,6 +41,11 @@ type Expandable interface {
41 ToggleExpanded()
41 ToggleExpanded()
42}
42}
43
43
44+// KeyEventHandler is an interface for items that can handle key events.
45+type KeyEventHandler interface {
46+ HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd)
47+}
48+
44// MessageItem represents a [message.Message] item that can be displayed in the
49// MessageItem represents a [message.Message] item that can be displayed in the
45// UI and be part of a [list.List] identifiable by a unique ID.
50// UI and be part of a [list.List] identifiable by a unique ID.
46type MessageItem interface {
51type MessageItem interface {
internal/ui/chat/tools.go+595
@@ -1,14 +1,19 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "encoding/json"
4 "fmt"
5 "fmt"
6+ "path/filepath"
5 "strings"
7 "strings"
8+ "time"
6
9
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
414}
419}
415
420
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")
426+ }
427+ return false, nil
428+}
429+
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
807
821
808 return sty.Tool.Body.Render(strings.Join(out, "\n"))
822 return sty.Tool.Body.Render(strings.Join(out, "\n"))
809}
823}
824+
825+// formatToolForCopy formats the tool call for clipboard copying.
826+func (t *baseToolMessageItem) formatToolForCopy() string {
827+ var parts []string
828+
829+ toolName := prettifyToolName(t.toolCall.Name)
830+ parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
831+
832+ if t.toolCall.Input != "" {
833+ params := t.formatParametersForCopy()
834+ if params != "" {
835+ parts = append(parts, "### Parameters:")
836+ parts = append(parts, params)
837+ }
838+ }
839+
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)
844+ } else {
845+ parts = append(parts, "### Result:")
846+ content := t.formatResultForCopy()
847+ if content != "" {
848+ parts = append(parts, content)
849+ }
850+ }
851+ } else if t.status == ToolStatusCanceled {
852+ parts = append(parts, "### Status:")
853+ parts = append(parts, "Cancelled")
854+ } else {
855+ parts = append(parts, "### Status:")
856+ parts = append(parts, "Pending...")
857+ }
858+
859+ return strings.Join(parts, "\n\n")
860+}
861+
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), &params) == nil {
868+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
869+ cmd = strings.ReplaceAll(cmd, "\t", " ")
870+ return fmt.Sprintf("**Command:** %s", cmd)
871+ }
872+ case tools.ViewToolName:
873+ var params tools.ViewParams
874+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
875+ var parts []string
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))
879+ }
880+ if params.Offset > 0 {
881+ parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
882+ }
883+ return strings.Join(parts, "\n")
884+ }
885+ case tools.EditToolName:
886+ var params tools.EditParams
887+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
888+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
889+ }
890+ case tools.MultiEditToolName:
891+ var params tools.MultiEditParams
892+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
893+ var parts []string
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")
897+ }
898+ case tools.WriteToolName:
899+ var params tools.WriteParams
900+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
901+ return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
902+ }
903+ case tools.FetchToolName:
904+ var params tools.FetchParams
905+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
906+ var parts []string
907+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
908+ if params.Format != "" {
909+ parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
910+ }
911+ if params.Timeout > 0 {
912+ parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
913+ }
914+ return strings.Join(parts, "\n")
915+ }
916+ case tools.AgenticFetchToolName:
917+ var params tools.AgenticFetchParams
918+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
919+ var parts []string
920+ if params.URL != "" {
921+ parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
922+ }
923+ if params.Prompt != "" {
924+ parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
925+ }
926+ return strings.Join(parts, "\n")
927+ }
928+ case tools.WebFetchToolName:
929+ var params tools.WebFetchParams
930+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
931+ return fmt.Sprintf("**URL:** %s", params.URL)
932+ }
933+ case tools.GrepToolName:
934+ var params tools.GrepParams
935+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
936+ var parts []string
937+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
938+ if params.Path != "" {
939+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
940+ }
941+ if params.Include != "" {
942+ parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
943+ }
944+ if params.LiteralText {
945+ parts = append(parts, "**Literal:** true")
946+ }
947+ return strings.Join(parts, "\n")
948+ }
949+ case tools.GlobToolName:
950+ var params tools.GlobParams
951+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
952+ var parts []string
953+ parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
954+ if params.Path != "" {
955+ parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
956+ }
957+ return strings.Join(parts, "\n")
958+ }
959+ case tools.LSToolName:
960+ var params tools.LSParams
961+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
962+ path := params.Path
963+ if path == "" {
964+ path = "."
965+ }
966+ return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
967+ }
968+ case tools.DownloadToolName:
969+ var params tools.DownloadParams
970+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
971+ var parts []string
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()))
976+ }
977+ return strings.Join(parts, "\n")
978+ }
979+ case tools.SourcegraphToolName:
980+ var params tools.SourcegraphParams
981+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
982+ var parts []string
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))
986+ }
987+ if params.ContextWindow > 0 {
988+ parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
989+ }
990+ return strings.Join(parts, "\n")
991+ }
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), &params) == nil {
997+ return fmt.Sprintf("**Task:**\n%s", params.Prompt)
998+ }
999+ }
1000+
1001+ var params map[string]any
1002+ if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
1003+ var parts []string
1004+ for key, value := range params {
1005+ displayKey := strings.ReplaceAll(key, "_", " ")
1006+ if len(displayKey) > 0 {
1007+ displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
1008+ }
1009+ parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
1010+ }
1011+ return strings.Join(parts, "\n")
1012+ }
1013+
1014+ return ""
1015+}
1016+
1017+// formatResultForCopy formats tool results for clipboard copying.
1018+func (t *baseToolMessageItem) formatResultForCopy() string {
1019+ if t.result == nil {
1020+ return ""
1021+ }
1022+
1023+ if t.result.Data != "" {
1024+ if strings.HasPrefix(t.result.MIMEType, "image/") {
1025+ return fmt.Sprintf("[Image: %s]", t.result.MIMEType)
1026+ }
1027+ return fmt.Sprintf("[Media: %s]", t.result.MIMEType)
1028+ }
1029+
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)
1051+ default:
1052+ return t.result.Content
1053+ }
1054+}
1055+
1056+// formatBashResultForCopy formats bash tool results for clipboard.
1057+func (t *baseToolMessageItem) formatBashResultForCopy() string {
1058+ if t.result == nil {
1059+ return ""
1060+ }
1061+
1062+ var meta tools.BashResponseMetadata
1063+ if t.result.Metadata != "" {
1064+ json.Unmarshal([]byte(t.result.Metadata), &meta)
1065+ }
1066+
1067+ output := meta.Output
1068+ if output == "" && t.result.Content != tools.BashNoOutput {
1069+ output = t.result.Content
1070+ }
1071+
1072+ if output == "" {
1073+ return ""
1074+ }
1075+
1076+ return fmt.Sprintf("```bash\n%s\n```", output)
1077+}
1078+
1079+// formatViewResultForCopy formats view tool results for clipboard.
1080+func (t *baseToolMessageItem) formatViewResultForCopy() string {
1081+ if t.result == nil {
1082+ return ""
1083+ }
1084+
1085+ var meta tools.ViewResponseMetadata
1086+ if t.result.Metadata != "" {
1087+ json.Unmarshal([]byte(t.result.Metadata), &meta)
1088+ }
1089+
1090+ if meta.Content == "" {
1091+ return t.result.Content
1092+ }
1093+
1094+ lang := ""
1095+ if meta.FilePath != "" {
1096+ ext := strings.ToLower(filepath.Ext(meta.FilePath))
1097+ switch ext {
1098+ case ".go":
1099+ lang = "go"
1100+ case ".js", ".mjs":
1101+ lang = "javascript"
1102+ case ".ts":
1103+ lang = "typescript"
1104+ case ".py":
1105+ lang = "python"
1106+ case ".rs":
1107+ lang = "rust"
1108+ case ".java":
1109+ lang = "java"
1110+ case ".c":
1111+ lang = "c"
1112+ case ".cpp", ".cc", ".cxx":
1113+ lang = "cpp"
1114+ case ".sh", ".bash":
1115+ lang = "bash"
1116+ case ".json":
1117+ lang = "json"
1118+ case ".yaml", ".yml":
1119+ lang = "yaml"
1120+ case ".xml":
1121+ lang = "xml"
1122+ case ".html":
1123+ lang = "html"
1124+ case ".css":
1125+ lang = "css"
1126+ case ".md":
1127+ lang = "markdown"
1128+ }
1129+ }
1130+
1131+ var result strings.Builder
1132+ if lang != "" {
1133+ result.WriteString(fmt.Sprintf("```%s\n", lang))
1134+ } else {
1135+ result.WriteString("```\n")
1136+ }
1137+ result.WriteString(meta.Content)
1138+ result.WriteString("\n```")
1139+
1140+ return result.String()
1141+}
1142+
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
1148+ }
1149+ return ""
1150+ }
1151+
1152+ var meta tools.EditResponseMetadata
1153+ if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1154+ return t.result.Content
1155+ }
1156+
1157+ var params tools.EditParams
1158+ json.Unmarshal([]byte(t.toolCall.Input), &params)
1159+
1160+ var result strings.Builder
1161+
1162+ if meta.OldContent != "" || meta.NewContent != "" {
1163+ fileName := params.FilePath
1164+ if fileName != "" {
1165+ fileName = fsext.PrettyPath(fileName)
1166+ }
1167+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1168+
1169+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
1170+ result.WriteString("```diff\n")
1171+ result.WriteString(diffContent)
1172+ result.WriteString("\n```")
1173+ }
1174+
1175+ return result.String()
1176+}
1177+
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
1183+ }
1184+ return ""
1185+ }
1186+
1187+ var meta tools.MultiEditResponseMetadata
1188+ if json.Unmarshal([]byte(t.result.Metadata), &meta) != nil {
1189+ return t.result.Content
1190+ }
1191+
1192+ var params tools.MultiEditParams
1193+ json.Unmarshal([]byte(t.toolCall.Input), &params)
1194+
1195+ var result strings.Builder
1196+ if meta.OldContent != "" || meta.NewContent != "" {
1197+ fileName := params.FilePath
1198+ if fileName != "" {
1199+ fileName = fsext.PrettyPath(fileName)
1200+ }
1201+ diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
1202+
1203+ result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
1204+ result.WriteString("```diff\n")
1205+ result.WriteString(diffContent)
1206+ result.WriteString("\n```")
1207+ }
1208+
1209+ return result.String()
1210+}
1211+
1212+// formatWriteResultForCopy formats write tool results for clipboard.
1213+func (t *baseToolMessageItem) formatWriteResultForCopy() string {
1214+ if t.result == nil {
1215+ return ""
1216+ }
1217+
1218+ var params tools.WriteParams
1219+ if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1220+ return t.result.Content
1221+ }
1222+
1223+ lang := ""
1224+ if params.FilePath != "" {
1225+ ext := strings.ToLower(filepath.Ext(params.FilePath))
1226+ switch ext {
1227+ case ".go":
1228+ lang = "go"
1229+ case ".js", ".mjs":
1230+ lang = "javascript"
1231+ case ".ts":
1232+ lang = "typescript"
1233+ case ".py":
1234+ lang = "python"
1235+ case ".rs":
1236+ lang = "rust"
1237+ case ".java":
1238+ lang = "java"
1239+ case ".c":
1240+ lang = "c"
1241+ case ".cpp", ".cc", ".cxx":
1242+ lang = "cpp"
1243+ case ".sh", ".bash":
1244+ lang = "bash"
1245+ case ".json":
1246+ lang = "json"
1247+ case ".yaml", ".yml":
1248+ lang = "yaml"
1249+ case ".xml":
1250+ lang = "xml"
1251+ case ".html":
1252+ lang = "html"
1253+ case ".css":
1254+ lang = "css"
1255+ case ".md":
1256+ lang = "markdown"
1257+ }
1258+ }
1259+
1260+ var result strings.Builder
1261+ result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
1262+ if lang != "" {
1263+ result.WriteString(fmt.Sprintf("```%s\n", lang))
1264+ } else {
1265+ result.WriteString("```\n")
1266+ }
1267+ result.WriteString(params.Content)
1268+ result.WriteString("\n```")
1269+
1270+ return result.String()
1271+}
1272+
1273+// formatFetchResultForCopy formats fetch tool results for clipboard.
1274+func (t *baseToolMessageItem) formatFetchResultForCopy() string {
1275+ if t.result == nil {
1276+ return ""
1277+ }
1278+
1279+ var params tools.FetchParams
1280+ if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1281+ return t.result.Content
1282+ }
1283+
1284+ var result strings.Builder
1285+ if params.URL != "" {
1286+ result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
1287+ }
1288+ if params.Format != "" {
1289+ result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
1290+ }
1291+ if params.Timeout > 0 {
1292+ result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
1293+ }
1294+ result.WriteString("\n")
1295+
1296+ result.WriteString(t.result.Content)
1297+
1298+ return result.String()
1299+}
1300+
1301+// formatAgenticFetchResultForCopy formats agentic fetch tool results for clipboard.
1302+func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string {
1303+ if t.result == nil {
1304+ return ""
1305+ }
1306+
1307+ var params tools.AgenticFetchParams
1308+ if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1309+ return t.result.Content
1310+ }
1311+
1312+ var result strings.Builder
1313+ if params.URL != "" {
1314+ result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
1315+ }
1316+ if params.Prompt != "" {
1317+ result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
1318+ }
1319+
1320+ result.WriteString("```markdown\n")
1321+ result.WriteString(t.result.Content)
1322+ result.WriteString("\n```")
1323+
1324+ return result.String()
1325+}
1326+
1327+// formatWebFetchResultForCopy formats web fetch tool results for clipboard.
1328+func (t *baseToolMessageItem) formatWebFetchResultForCopy() string {
1329+ if t.result == nil {
1330+ return ""
1331+ }
1332+
1333+ var params tools.WebFetchParams
1334+ if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
1335+ return t.result.Content
1336+ }
1337+
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```")
1343+
1344+ return result.String()
1345+}
1346+
1347+// formatAgentResultForCopy formats agent tool results for clipboard.
1348+func (t *baseToolMessageItem) formatAgentResultForCopy() string {
1349+ if t.result == nil {
1350+ return ""
1351+ }
1352+
1353+ var result strings.Builder
1354+
1355+ if t.result.Content != "" {
1356+ result.WriteString(fmt.Sprintf("```markdown\n%s\n```", t.result.Content))
1357+ }
1358+
1359+ return result.String()
1360+}
1361+
1362+// prettifyToolName returns a human-readable name for tool names.
1363+func prettifyToolName(name string) string {
1364+ switch name {
1365+ case agent.AgentToolName:
1366+ return "Agent"
1367+ case tools.BashToolName:
1368+ return "Bash"
1369+ case tools.JobOutputToolName:
1370+ return "Job: Output"
1371+ case tools.JobKillToolName:
1372+ return "Job: Kill"
1373+ case tools.DownloadToolName:
1374+ return "Download"
1375+ case tools.EditToolName:
1376+ return "Edit"
1377+ case tools.MultiEditToolName:
1378+ return "Multi-Edit"
1379+ case tools.FetchToolName:
1380+ return "Fetch"
1381+ case tools.AgenticFetchToolName:
1382+ return "Agentic Fetch"
1383+ case tools.WebFetchToolName:
1384+ return "Fetch"
1385+ case tools.WebSearchToolName:
1386+ return "Search"
1387+ case tools.GlobToolName:
1388+ return "Glob"
1389+ case tools.GrepToolName:
1390+ return "Grep"
1391+ case tools.LSToolName:
1392+ return "List"
1393+ case tools.SourcegraphToolName:
1394+ return "Sourcegraph"
1395+ case tools.TodosToolName:
1396+ return "To-Do"
1397+ case tools.ViewToolName:
1398+ return "View"
1399+ case tools.WriteToolName:
1400+ return "Write"
1401+ default:
1402+ return name
1403+ }
1404+}
internal/ui/chat/user.go+10
@@ -3,6 +3,7 @@ package chat
3import (
3import (
4 "strings"
4 "strings"
5
5
6+ tea "charm.land/bubbletea/v2"
6 "charm.land/lipgloss/v2"
7 "charm.land/lipgloss/v2"
7 "github.com/charmbracelet/crush/internal/message"
8 "github.com/charmbracelet/crush/internal/message"
8 "github.com/charmbracelet/crush/internal/ui/attachments"
9 "github.com/charmbracelet/crush/internal/ui/attachments"
@@ -92,3 +93,12 @@ func (m *UserMessageItem) renderAttachments(width int) string {
92 }
93 }
93 return m.attachments.Render(attachments, false, width)
94 return m.attachments.Render(attachments, false, width)
94}
95}
96+
97+// HandleKeyEvent implements KeyEventHandler.
98+func (m *UserMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
99+ if key.String() == "c" {
100+ text := m.message.Content().Text
101+ return true, common.CopyToClipboard(text, "Message copied to clipboard")
102+ }
103+ return false, nil
104+}
internal/ui/common/common.go+25
@@ -5,9 +5,12 @@ import (
5 "image"
5 "image"
6 "os"
6 "os"
7
7
8+ tea "charm.land/bubbletea/v2"
9+ "github.com/atotto/clipboard"
8 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/app"
9 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/crush/internal/uiutil"
11 uv "github.com/charmbracelet/ultraviolet"
14 uv "github.com/charmbracelet/ultraviolet"
12)
15)
13
16
@@ -63,3 +66,25 @@ func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
63
66
64 return false, nil
67 return false, nil
65}
68}
69+
70+// CopyToClipboard copies the given text to the clipboard using both OSC 52
71+// (terminal escape sequence) and native clipboard for maximum compatibility.
72+// Returns a command that reports success to the user with the given message.
73+func CopyToClipboard(text, successMessage string) tea.Cmd {
74+ return CopyToClipboardWithCallback(text, successMessage, nil)
75+}
76+
77+// CopyToClipboardWithCallback copies text to clipboard and executes a callback
78+// before showing the success message.
79+// This is useful when you need to perform additional actions like clearing UI state.
80+func CopyToClipboardWithCallback(text, successMessage string, callback tea.Cmd) tea.Cmd {
81+ return tea.Sequence(
82+ tea.SetClipboard(text),
83+ func() tea.Msg {
84+ _ = clipboard.WriteAll(text)
85+ return nil
86+ },
87+ callback,
88+ uiutil.ReportInfo(successMessage),
89+ )
90+}
internal/ui/model/chat.go+10
@@ -411,6 +411,16 @@ func (m *Chat) ToggleExpandedSelectedItem() {
411 }
411 }
412}
412}
413
413
414+// HandleKeyMsg handles key events for the chat component.
415+func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
416+ if m.list.Focused() {
417+ if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
418+ return handler.HandleKeyEvent(key)
419+ }
420+ }
421+ return false, nil
422+}
423+
414// HandleMouseDown handles mouse down events for the chat component.
424// HandleMouseDown handles mouse down events for the chat component.
415func (m *Chat) HandleMouseDown(x, y int) bool {
425func (m *Chat) HandleMouseDown(x, y int) bool {
416 if m.list.Len() == 0 {
426 if m.list.Len() == 0 {
internal/ui/model/ui.go+8−9
@@ -23,7 +23,6 @@ import (
23 "charm.land/bubbles/v2/textarea"
23 "charm.land/bubbles/v2/textarea"
24 tea "charm.land/bubbletea/v2"
24 tea "charm.land/bubbletea/v2"
25 "charm.land/lipgloss/v2"
25 "charm.land/lipgloss/v2"
26− "github.com/atotto/clipboard"
27 "github.com/charmbracelet/catwalk/pkg/catwalk"
26 "github.com/charmbracelet/catwalk/pkg/catwalk"
28 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
27 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
29 "github.com/charmbracelet/crush/internal/app"
28 "github.com/charmbracelet/crush/internal/app"
@@ -1619,7 +1618,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1619 }
1618 }
1620 m.chat.SelectLast()
1619 m.chat.SelectLast()
1621 default:
1620 default:
1621+ if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1622+ cmds = append(cmds, cmd)
1623+ } else {
1622− handleGlobalKeys(msg)
1624+ handleGlobalKeys(msg)
1625+ }
1623 }
1626 }
1624 default:
1627 default:
1625 handleGlobalKeys(msg)
1628 handleGlobalKeys(msg)
@@ -2918,17 +2921,13 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
2918
2921
2919func (m *UI) copyChatHighlight() tea.Cmd {
2922func (m *UI) copyChatHighlight() tea.Cmd {
2920 text := m.chat.HighlightContent()
2923 text := m.chat.HighlightContent()
2921− return tea.Sequence(
2924+ return common.CopyToClipboardWithCallback(
2922− tea.SetClipboard(text),
2925+ text,
2923− func() tea.Msg {
2926+ "Selected text copied to clipboard",
2924− _ = clipboard.WriteAll(text)
2925− return nil
2926− },
2927 func() tea.Msg {
2927 func() tea.Msg {
2928 m.chat.ClearMouse()
2928 m.chat.ClearMouse()
2929 return nil
2929 return nil
2930 },
2930 },
2931− uiutil.ReportInfo("Selected text copied to clipboard"),
2932 )
2931 )
2933}
2932}
2934
2933