From a4f5d722761e9d3a8e90c5d9c3662b57ada6fe71 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 7 Jan 2026 12:24:46 -0500 Subject: [PATCH 1/6] docs(README): add FreeBSD installation instructions --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d876ef648b8237a4e2a172c23acfe5e05ec386b..4135ddea8c6209f05989e64ddc356a2244efbb03 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ yay -S crush-bin # Nix nix run github:numtide/nix-ai-tools#crush + +# FreeBSD +pkg install crush ``` Windows users: @@ -52,9 +55,9 @@ scoop install crush
Nix (NUR) -Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`. +Crush is available via the offical Charm [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`, which is the most up-to-date way to get Crush in Nix. -You can also try out Crush via `nix-shell`: +You can also try out Crush via the NUR with `nix-shell`: ```bash # Add the NUR channel. From 666fabd1cfeffdb9d8d2b0a2d42aa876e52a45a7 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:10:00 -0300 Subject: [PATCH 2/6] chore(legal): @mohaanymo has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 7ab467b0f9ea6d6f052e7acb7b35b43a59bc6e49..2d61c78213aef0bafa4066451f321487263e6e6e 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1007,6 +1007,14 @@ "created_at": "2026-01-01T21:00:07Z", "repoId": 987670088, "pullRequestNo": 1748 + }, + { + "name": "mohaanymo", + "id": 244024658, + "comment_id": 3725028621, + "created_at": "2026-01-08T18:01:11Z", + "repoId": 987670088, + "pullRequestNo": 1799 } ] } \ No newline at end of file From 15de35288a79a2b4d979e6f81663c81a83a749cf Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:44:14 -0300 Subject: [PATCH 3/6] chore(legal): @zyriab has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 2d61c78213aef0bafa4066451f321487263e6e6e..e7e6a072edd662f7a22223846d31347ed10f405e 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1015,6 +1015,14 @@ "created_at": "2026-01-08T18:01:11Z", "repoId": 987670088, "pullRequestNo": 1799 + }, + { + "name": "zyriab", + "id": 2111910, + "comment_id": 3725966281, + "created_at": "2026-01-08T21:44:05Z", + "repoId": 987670088, + "pullRequestNo": 1801 } ] } \ No newline at end of file From 467418d5346b5c253dc141ef44d60ca3e1f1f6f8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 08:44:02 -0300 Subject: [PATCH 4/6] feat: open editor in the right position (#1804) Signed-off-by: Carlos Alexandro Becker --- go.mod | 3 ++- go.sum | 6 +++-- internal/tui/components/chat/editor/editor.go | 27 +++++++++---------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 8877336bd0a42158b2a96b3c34c6544d190f2151..a5fe985d15007fcf5176ca4e3c9fadb0095a0905 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/charmbracelet/crush go 1.25.5 require ( - charm.land/bubbles/v2 v2.0.0-rc.1 + charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e charm.land/fantasy v0.6.0 charm.land/glamour/v2 v2.0.0-20251110203732-69649f93d3b1 @@ -23,6 +23,7 @@ require ( github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 github.com/charmbracelet/x/ansi v0.11.3 + github.com/charmbracelet/x/editor v0.2.0 github.com/charmbracelet/x/etag v0.2.0 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f diff --git a/go.sum b/go.sum index 29ac00482dedaeb5f7444810b7194a367c6f67a9..3131993a7dd1473522938031ebb2be23b906e061 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM= -charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= +charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv9xq6ZS+x0mtacfxpxjIK1KUIeTqBOs= +charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8= charm.land/fantasy v0.6.0 h1:0PZfZ/w6c70UdlumGGFW6s9zTV6f4xAV/bXo6vGuZsc= @@ -104,6 +104,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 h1:j3PW2 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560/go.mod h1:VWATWLRwYP06VYCEur7FsNR2B1xAo7Y+xl1PTbd1ePc= github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= +github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk= +github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 972824be0599fb37651f8b607a90114387a73f3c..8f9b326b9f941bb99cfbaad992830c3173ea41c4 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -1,14 +1,12 @@ package editor import ( - "context" "errors" "fmt" "math/rand" "net/http" "os" "path/filepath" - "runtime" "slices" "strings" "unicode" @@ -32,6 +30,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/editor" ) type Editor interface { @@ -94,16 +93,6 @@ type OpenEditorMsg struct { } func (m *editorCmp) openEditor(value string) tea.Cmd { - editor := os.Getenv("EDITOR") - if editor == "" { - // Use platform-appropriate default editor - if runtime.GOOS == "windows" { - editor = "notepad" - } else { - editor = "nvim" - } - } - tmpfile, err := os.CreateTemp("", "msg_*.md") if err != nil { return util.ReportError(err) @@ -112,8 +101,18 @@ func (m *editorCmp) openEditor(value string) tea.Cmd { if _, err := tmpfile.WriteString(value); err != nil { return util.ReportError(err) } - cmdStr := editor + " " + tmpfile.Name() - return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg { + cmd, err := editor.Command( + "crush", + tmpfile.Name(), + editor.AtPosition( + m.textarea.Line()+1, + m.textarea.Column()+1, + ), + ) + if err != nil { + return util.ReportError(err) + } + return tea.ExecProcess(cmd, func(err error) tea.Msg { if err != nil { return util.ReportError(err) } From 23c6e43b2a4f5b3d86cc8c4fffef41ce81d6baf4 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:28:30 -0300 Subject: [PATCH 5/6] chore(legal): @aleksclark has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index e7e6a072edd662f7a22223846d31347ed10f405e..a18c4ccb7d31b709a1f19ababb62843a5a924349 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1023,6 +1023,14 @@ "created_at": "2026-01-08T21:44:05Z", "repoId": 987670088, "pullRequestNo": 1801 + }, + { + "name": "aleksclark", + "id": 607132, + "comment_id": 3729687747, + "created_at": "2026-01-09T16:28:21Z", + "repoId": 987670088, + "pullRequestNo": 1811 } ] } \ No newline at end of file From 617f9e28f634089fed552cc28a987851f23c48e0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jan 2026 14:12:05 -0300 Subject: [PATCH 6/6] feat: allow to send the prompt if its empty but has text attachments (#1806) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/agent/agent.go | 2 +- internal/message/attachment.go | 12 +++- internal/tui/components/chat/editor/editor.go | 72 ++++++++++--------- .../tui/components/chat/messages/messages.go | 8 ++- 4 files changed, 56 insertions(+), 38 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 759a9274f2f4cc8c306ac0cc042de89cd1a25097..7c7ac4c6c1f3d320fe3e3dd865f8e7b56c73010d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -134,7 +134,7 @@ func NewSessionAgent( } func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) { - if call.Prompt == "" { + if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) { return nil, ErrEmptyPrompt } if call.SessionID == "" { diff --git a/internal/message/attachment.go b/internal/message/attachment.go index 0e3b70a8766c74d37399c1ba8c38fe19e74f871d..b04863f39cc5b266662395344d5227cfa12f4188 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -1,6 +1,9 @@ package message -import "strings" +import ( + "slices" + "strings" +) type Attachment struct { FilePath string @@ -11,3 +14,10 @@ type Attachment struct { func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") } func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") } + +// ContainsTextAttachment returns true if any of the attachments is a text attachments. +func ContainsTextAttachment(attachments []Attachment) bool { + return slices.ContainsFunc(attachments, func(a Attachment) bool { + return a.IsText() + }) +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 8f9b326b9f941bb99cfbaad992830c3173ea41c4..01badb98d37eb848ccf5962e01793ecaa3fc0f59 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -1,13 +1,14 @@ package editor import ( - "errors" "fmt" "math/rand" "net/http" "os" "path/filepath" + "regexp" "slices" + "strconv" "strings" "unicode" @@ -146,7 +147,7 @@ func (m *editorCmp) send() tea.Cmd { attachments := m.attachments - if value == "" { + if value == "" && !message.ContainsTextAttachment(attachments) { return nil } @@ -233,13 +234,31 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.textarea.SetValue(msg.Text) m.textarea.MoveToEnd() case tea.PasteMsg: - content, path, err := pasteToFile(msg) - if errors.Is(err, errNotAFile) { - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd + // If pasted text has more than 2 newlines, treat it as a file attachment. + if strings.Count(msg.Content, "\n") > 2 { + content := []byte(msg.Content) + if len(content) > maxAttachmentSize { + return m, util.ReportWarn("Paste is too big (>5mb)") + } + name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) + mimeType := mimeOf(content) + attachment := message.Attachment{ + FileName: name, + FilePath: name, + MimeType: mimeType, + Content: content, + } + return m, util.CmdHandler(filepicker.FilePickedMsg{ + Attachment: attachment, + }) } + + // Try to parse as a file path. + content, path, err := filepathToFile(msg.Content) if err != nil { - return m, util.ReportError(err) + // Not a file path, just update the textarea normally. + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd } if len(content) > maxAttachmentSize { @@ -256,7 +275,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { if !attachment.IsText() && !attachment.IsImage() { return m, util.ReportWarn("Invalid file content type: " + mimeType) } - m.textarea.InsertString(attachment.FileName) return m, util.CmdHandler(filepicker.FilePickedMsg{ Attachment: attachment, }) @@ -627,33 +645,21 @@ func New(app *app.App) Editor { var maxAttachmentSize = 5 * 1024 * 1024 // 5MB -var errNotAFile = errors.New("not a file") - -func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) { - content, path, err := filepathToFile(msg.Content) - if err == nil { - return content, path, err - } - - if strings.Count(msg.Content, "\n") > 2 { - return contentToFile([]byte(msg.Content)) - } - - return nil, "", errNotAFile -} +var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) -func contentToFile(content []byte) ([]byte, string, error) { - f, err := os.CreateTemp("", "paste_*.txt") - if err != nil { - return nil, "", err - } - if _, err := f.Write(content); err != nil { - return nil, "", err - } - if err := f.Close(); err != nil { - return nil, "", err +func (m *editorCmp) pasteIdx() int { + result := 0 + for _, at := range m.attachments { + found := pasteRE.FindStringSubmatch(at.FileName) + if len(found) == 0 { + continue + } + idx, err := strconv.Atoi(found[1]) + if err == nil { + result = max(result, idx) + } } - return content, f.Name(), nil + return result + 1 } func filepathToFile(name string) ([]byte, string, error) { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 1359823edb7a783cd23b600e1ddae3870f2a2107..b4db149946fe0a1f67c957eeb04da2966e1f5f28 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -223,8 +223,10 @@ func (m *messageCmp) renderAssistantMessage() string { // message content and any attached files with appropriate icons. func (m *messageCmp) renderUserMessage() string { t := styles.CurrentTheme() - parts := []string{ - m.toMarkdown(m.message.Content().String()), + var parts []string + + if s := m.message.Content().String(); s != "" { + parts = append(parts, m.toMarkdown(s)) } attachmentStyle := t.S().Base. @@ -256,7 +258,7 @@ func (m *messageCmp) renderUserMessage() string { } if len(attachments) > 0 { - parts = append(parts, "", strings.Join(attachments, "")) + parts = append(parts, strings.Join(attachments, "")) } joined := lipgloss.JoinVertical(lipgloss.Left, parts...)