on main help sign in
feat(ui): add keybinding to copy chat message content to clipboard (#1947)

* feat(ui): add keybinding to copy chat message content to clipboard This commit backports the ability to copy the content of chat messages (assistant, user, and tool messages) to the clipboard using the 'c' key when the message is focused. * feat(ui): format tool calls and results for clipboard copying

commit88d10d13367eb0722898196b78a422cf98b6c21e authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 23 Jan 2026, 15:06 (9 months ago) parent8a37a34
7 files changed+662−9
internal/ui/chat/assistant.go+9 internal/ui/chat/messages.go+5 internal/ui/chat/tools.go+595 internal/ui/chat/user.go+10 internal/ui/common/common.go+25 internal/ui/model/chat.go+10 internal/ui/model/ui.go+8−9
internal/ui/chat/assistant.go+9
@@ -255,3 +255,12 @@ func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int)
255255 }
256256 return false
257257}
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 {
4141 ToggleExpanded()
4242}
4343
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+
4449// MessageItem represents a [message.Message] item that can be displayed in the
4550// UI and be part of a [list.List] identifiable by a unique ID.
4651type MessageItem interface {
internal/ui/chat/tools.go+595
@@ -1,14 +1,19 @@
11package chat
22
33import (
4+ "encoding/json"
45 "fmt"
6+ "path/filepath"
57 "strings"
8+ "time"
69
710 tea "charm.land/bubbletea/v2"
811 "charm.land/lipgloss/v2"
912 "charm.land/lipgloss/v2/tree"
1013 "github.com/charmbracelet/crush/internal/agent"
1114 "github.com/charmbracelet/crush/internal/agent/tools"
15+ "github.com/charmbracelet/crush/internal/diff"
16+ "github.com/charmbracelet/crush/internal/fsext"
1217 "github.com/charmbracelet/crush/internal/message"
1318 "github.com/charmbracelet/crush/internal/ui/anim"
1419 "github.com/charmbracelet/crush/internal/ui/common"
@@ -413,6 +418,15 @@ func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) b
413418 return true
414419}
415420
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+
416430// pendingTool renders a tool that is still in progress with an animation.
417431func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
418432 icon := sty.Tool.IconPending.Render()
@@ -807,3 +821,584 @@ func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, ex
807821
808822 return sty.Tool.Body.Render(strings.Join(out, "\n"))
809823}
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
33import (
44 "strings"
55
6+ tea "charm.land/bubbletea/v2"
67 "charm.land/lipgloss/v2"
78 "github.com/charmbracelet/crush/internal/message"
89 "github.com/charmbracelet/crush/internal/ui/attachments"
@@ -92,3 +93,12 @@ func (m *UserMessageItem) renderAttachments(width int) string {
9293 }
9394 return m.attachments.Render(attachments, false, width)
9495}
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 (
55 "image"
66 "os"
77
8+ tea "charm.land/bubbletea/v2"
9+ "github.com/atotto/clipboard"
810 "github.com/charmbracelet/crush/internal/app"
911 "github.com/charmbracelet/crush/internal/config"
1012 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/crush/internal/uiutil"
1114 uv "github.com/charmbracelet/ultraviolet"
1215)
1316
@@ -63,3 +66,25 @@ func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
6366
6467 return false, nil
6568}
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() {
411411 }
412412}
413413
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+
414424// HandleMouseDown handles mouse down events for the chat component.
415425func (m *Chat) HandleMouseDown(x, y int) bool {
416426 if m.list.Len() == 0 {
internal/ui/model/ui.go+8−9
@@ -23,7 +23,6 @@ import (
2323 "charm.land/bubbles/v2/textarea"
2424 tea "charm.land/bubbletea/v2"
2525 "charm.land/lipgloss/v2"
26− "github.com/atotto/clipboard"
2726 "github.com/charmbracelet/catwalk/pkg/catwalk"
2827 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2928 "github.com/charmbracelet/crush/internal/app"
@@ -1619,7 +1618,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
16191618 }
16201619 m.chat.SelectLast()
16211620 default:
1622− handleGlobalKeys(msg)
1621+ if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1622+ cmds = append(cmds, cmd)
1623+ } else {
1624+ handleGlobalKeys(msg)
1625+ }
16231626 }
16241627 default:
16251628 handleGlobalKeys(msg)
@@ -2918,17 +2921,13 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
29182921
29192922func (m *UI) copyChatHighlight() tea.Cmd {
29202923 text := m.chat.HighlightContent()
2921− return tea.Sequence(
2922− tea.SetClipboard(text),
2923− func() tea.Msg {
2924− _ = clipboard.WriteAll(text)
2925− return nil
2926− },
2924+ return common.CopyToClipboardWithCallback(
2925+ text,
2926+ "Selected text copied to clipboard",
29272927 func() tea.Msg {
29282928 m.chat.ClearMouse()
29292929 return nil
29302930 },
2931− uiutil.ReportInfo("Selected text copied to clipboard"),
29322931 )
29332932}
29342933