on main help sign in
Merge remote-tracking branch 'origin/main' into ui

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commit7fb60382bb32cd4e759c781e0344aea68526f44b authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 9 Jan 2026, 14:12 (9 months ago) parent030c8fc617f9e2
4 files changed+86−52
.github/cla-signatures.json+24 README.md+5−2 internal/tui/components/chat/editor/editor.go+52−47 internal/tui/components/chat/messages/messages.go+5−3
.github/cla-signatures.json+24
@@ -1007,6 +1007,30 @@
10071007 "created_at": "2026-01-01T21:00:07Z",
10081008 "repoId": 987670088,
10091009 "pullRequestNo": 1748
1010+ },
1011+ {
1012+ "name": "mohaanymo",
1013+ "id": 244024658,
1014+ "comment_id": 3725028621,
1015+ "created_at": "2026-01-08T18:01:11Z",
1016+ "repoId": 987670088,
1017+ "pullRequestNo": 1799
1018+ },
1019+ {
1020+ "name": "zyriab",
1021+ "id": 2111910,
1022+ "comment_id": 3725966281,
1023+ "created_at": "2026-01-08T21:44:05Z",
1024+ "repoId": 987670088,
1025+ "pullRequestNo": 1801
1026+ },
1027+ {
1028+ "name": "aleksclark",
1029+ "id": 607132,
1030+ "comment_id": 3729687747,
1031+ "created_at": "2026-01-09T16:28:21Z",
1032+ "repoId": 987670088,
1033+ "pullRequestNo": 1811
10101034 }
10111035 ]
10121036}
README.md+5−2
@@ -36,6 +36,9 @@ yay -S crush-bin
3636
3737# Nix
3838nix run github:numtide/nix-ai-tools#crush
39+
40+# FreeBSD
41+pkg install crush
3942```
4043
4144Windows users:
@@ -52,9 +55,9 @@ scoop install crush
5255<details>
5356<summary><strong>Nix (NUR)</strong></summary>
5457
55−Crush is available via [NUR](https://github.com/nix-community/NUR) in `nur.repos.charmbracelet.crush`.
58+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.
5659
57−You can also try out Crush via `nix-shell`:
60+You can also try out Crush via the NUR with `nix-shell`:
5861
5962```bash
6063# Add the NUR channel.
internal/tui/components/chat/editor/editor.go+52−47
@@ -1,15 +1,14 @@
11package editor
22
33import (
4− "context"
5− "errors"
64 "fmt"
75 "math/rand"
86 "net/http"
97 "os"
108 "path/filepath"
11− "runtime"
9+ "regexp"
1210 "slices"
11+ "strconv"
1312 "strings"
1413 "unicode"
1514
@@ -32,6 +31,7 @@ import (
3231 "github.com/charmbracelet/crush/internal/tui/styles"
3332 "github.com/charmbracelet/crush/internal/tui/util"
3433 "github.com/charmbracelet/x/ansi"
34+ "github.com/charmbracelet/x/editor"
3535)
3636
3737type Editor interface {
@@ -94,16 +94,6 @@ type OpenEditorMsg struct {
9494}
9595
9696func (m *editorCmp) openEditor(value string) tea.Cmd {
97− editor := os.Getenv("EDITOR")
98− if editor == "" {
99− // Use platform-appropriate default editor
100− if runtime.GOOS == "windows" {
101− editor = "notepad"
102− } else {
103− editor = "nvim"
104− }
105− }
106−
10797 tmpfile, err := os.CreateTemp("", "msg_*.md")
10898 if err != nil {
10999 return util.ReportError(err)
@@ -112,8 +102,18 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
112102 if _, err := tmpfile.WriteString(value); err != nil {
113103 return util.ReportError(err)
114104 }
115− cmdStr := editor + " " + tmpfile.Name()
116− return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
105+ cmd, err := editor.Command(
106+ "crush",
107+ tmpfile.Name(),
108+ editor.AtPosition(
109+ m.textarea.Line()+1,
110+ m.textarea.Column()+1,
111+ ),
112+ )
113+ if err != nil {
114+ return util.ReportError(err)
115+ }
116+ return tea.ExecProcess(cmd, func(err error) tea.Msg {
117117 if err != nil {
118118 return util.ReportError(err)
119119 }
@@ -147,7 +147,7 @@ func (m *editorCmp) send() tea.Cmd {
147147
148148 attachments := m.attachments
149149
150− if value == "" {
150+ if value == "" && !message.ContainsTextAttachment(attachments) {
151151 return nil
152152 }
153153
@@ -234,13 +234,31 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
234234 m.textarea.SetValue(msg.Text)
235235 m.textarea.MoveToEnd()
236236 case tea.PasteMsg:
237− content, path, err := pasteToFile(msg)
238− if errors.Is(err, errNotAFile) {
239− m.textarea, cmd = m.textarea.Update(msg)
240− return m, cmd
237+ // If pasted text has more than 2 newlines, treat it as a file attachment.
238+ if strings.Count(msg.Content, "\n") > 2 {
239+ content := []byte(msg.Content)
240+ if len(content) > maxAttachmentSize {
241+ return m, util.ReportWarn("Paste is too big (>5mb)")
242+ }
243+ name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
244+ mimeType := mimeOf(content)
245+ attachment := message.Attachment{
246+ FileName: name,
247+ FilePath: name,
248+ MimeType: mimeType,
249+ Content: content,
250+ }
251+ return m, util.CmdHandler(filepicker.FilePickedMsg{
252+ Attachment: attachment,
253+ })
241254 }
255+
256+ // Try to parse as a file path.
257+ content, path, err := filepathToFile(msg.Content)
242258 if err != nil {
243− return m, util.ReportError(err)
259+ // Not a file path, just update the textarea normally.
260+ m.textarea, cmd = m.textarea.Update(msg)
261+ return m, cmd
244262 }
245263
246264 if len(content) > maxAttachmentSize {
@@ -257,7 +275,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
257275 if !attachment.IsText() && !attachment.IsImage() {
258276 return m, util.ReportWarn("Invalid file content type: " + mimeType)
259277 }
260− m.textarea.InsertString(attachment.FileName)
261278 return m, util.CmdHandler(filepicker.FilePickedMsg{
262279 Attachment: attachment,
263280 })
@@ -628,33 +645,21 @@ func New(app *app.App) Editor {
628645
629646var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
630647
631−var errNotAFile = errors.New("not a file")
632−
633−func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
634− content, path, err := filepathToFile(msg.Content)
635− if err == nil {
636− return content, path, err
637− }
638−
639− if strings.Count(msg.Content, "\n") > 2 {
640− return contentToFile([]byte(msg.Content))
641− }
642−
643− return nil, "", errNotAFile
644−}
648+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
645649
646−func contentToFile(content []byte) ([]byte, string, error) {
647− f, err := os.CreateTemp("", "paste_*.txt")
648− if err != nil {
649− return nil, "", err
650− }
651− if _, err := f.Write(content); err != nil {
652− return nil, "", err
653− }
654− if err := f.Close(); err != nil {
655− return nil, "", err
650+func (m *editorCmp) pasteIdx() int {
651+ result := 0
652+ for _, at := range m.attachments {
653+ found := pasteRE.FindStringSubmatch(at.FileName)
654+ if len(found) == 0 {
655+ continue
656+ }
657+ idx, err := strconv.Atoi(found[1])
658+ if err == nil {
659+ result = max(result, idx)
660+ }
656661 }
657− return content, f.Name(), nil
662+ return result + 1
658663}
659664
660665func filepathToFile(name string) ([]byte, string, error) {
internal/tui/components/chat/messages/messages.go+5−3
@@ -223,8 +223,10 @@ func (m *messageCmp) renderAssistantMessage() string {
223223// message content and any attached files with appropriate icons.
224224func (m *messageCmp) renderUserMessage() string {
225225 t := styles.CurrentTheme()
226− parts := []string{
227− m.toMarkdown(m.message.Content().String()),
226+ var parts []string
227+
228+ if s := m.message.Content().String(); s != "" {
229+ parts = append(parts, m.toMarkdown(s))
228230 }
229231
230232 attachmentStyle := t.S().Base.
@@ -256,7 +258,7 @@ func (m *messageCmp) renderUserMessage() string {
256258 }
257259
258260 if len(attachments) > 0 {
259− parts = append(parts, "", strings.Join(attachments, ""))
261+ parts = append(parts, strings.Join(attachments, ""))
260262 }
261263
262264 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)