diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 7ab467b0f9ea6d6f052e7acb7b35b43a59bc6e49..a18c4ccb7d31b709a1f19ababb62843a5a924349 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1007,6 +1007,30 @@ "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 + }, + { + "name": "zyriab", + "id": 2111910, + "comment_id": 3725966281, + "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 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. diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 972824be0599fb37651f8b607a90114387a73f3c..01badb98d37eb848ccf5962e01793ecaa3fc0f59 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -1,15 +1,14 @@ package editor import ( - "context" - "errors" "fmt" "math/rand" "net/http" "os" "path/filepath" - "runtime" + "regexp" "slices" + "strconv" "strings" "unicode" @@ -32,6 +31,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 +94,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 +102,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) } @@ -147,7 +147,7 @@ func (m *editorCmp) send() tea.Cmd { attachments := m.attachments - if value == "" { + if value == "" && !message.ContainsTextAttachment(attachments) { return nil } @@ -234,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 { @@ -257,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, }) @@ -628,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...)