on main help sign in
Merge remote-tracking branch 'origin/main' into ui
7fb6038Carlos Alexandro Becker
.github/cla-signatures.json+24
@@ -1007,6 +1007,30 @@
1007 "created_at": "2026-01-01T21:00:07Z",
1007 "created_at": "2026-01-01T21:00:07Z",
1008 "repoId": 987670088,
1008 "repoId": 987670088,
1009 "pullRequestNo": 1748
1009 "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
1010 }
1034 }
1011 ]
1035 ]
1012}
1036}
README.md+5−2
@@ -36,6 +36,9 @@ yay -S crush-bin
36
36
37# Nix
37# Nix
38nix run github:numtide/nix-ai-tools#crush
38nix run github:numtide/nix-ai-tools#crush
39+
40+# FreeBSD
41+pkg install crush
39```
42```
40
43
41Windows users:
44Windows users:
@@ -52,9 +55,9 @@ scoop install crush
52<details>
55<details>
53<summary><strong>Nix (NUR)</strong></summary>
56<summary><strong>Nix (NUR)</strong></summary>
54
57
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.
56
59
57−You can also try out Crush via `nix-shell`:
60+You can also try out Crush via the NUR with `nix-shell`:
58
61
59```bash
62```bash
60# Add the NUR channel.
63# Add the NUR channel.
internal/tui/components/chat/editor/editor.go+52−47
@@ -1,15 +1,14 @@
1package editor
1package editor
2
2
3import (
3import (
4− "context"
5− "errors"
6 "fmt"
4 "fmt"
7 "math/rand"
5 "math/rand"
8 "net/http"
6 "net/http"
9 "os"
7 "os"
10 "path/filepath"
8 "path/filepath"
11− "runtime"
9+ "regexp"
12 "slices"
10 "slices"
11+ "strconv"
13 "strings"
12 "strings"
14 "unicode"
13 "unicode"
15
14
@@ -32,6 +31,7 @@ import (
32 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/styles"
33 "github.com/charmbracelet/crush/internal/tui/util"
32 "github.com/charmbracelet/crush/internal/tui/util"
34 "github.com/charmbracelet/x/ansi"
33 "github.com/charmbracelet/x/ansi"
34+ "github.com/charmbracelet/x/editor"
35)
35)
36
36
37type Editor interface {
37type Editor interface {
@@ -94,16 +94,6 @@ type OpenEditorMsg struct {
94}
94}
95
95
96func (m *editorCmp) openEditor(value string) tea.Cmd {
96func (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−
107 tmpfile, err := os.CreateTemp("", "msg_*.md")
97 tmpfile, err := os.CreateTemp("", "msg_*.md")
108 if err != nil {
98 if err != nil {
109 return util.ReportError(err)
99 return util.ReportError(err)
@@ -112,8 +102,18 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
112 if _, err := tmpfile.WriteString(value); err != nil {
102 if _, err := tmpfile.WriteString(value); err != nil {
113 return util.ReportError(err)
103 return util.ReportError(err)
114 }
104 }
105+ cmd, err := editor.Command(
106+ "crush",
115− cmdStr := editor + " " + tmpfile.Name()
107+ tmpfile.Name(),
116− return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
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 {
117 if err != nil {
117 if err != nil {
118 return util.ReportError(err)
118 return util.ReportError(err)
119 }
119 }
@@ -147,7 +147,7 @@ func (m *editorCmp) send() tea.Cmd {
147
147
148 attachments := m.attachments
148 attachments := m.attachments
149
149
150− if value == "" {
150+ if value == "" && !message.ContainsTextAttachment(attachments) {
151 return nil
151 return nil
152 }
152 }
153
153
@@ -234,13 +234,31 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
234 m.textarea.SetValue(msg.Text)
234 m.textarea.SetValue(msg.Text)
235 m.textarea.MoveToEnd()
235 m.textarea.MoveToEnd()
236 case tea.PasteMsg:
236 case tea.PasteMsg:
237− content, path, err := pasteToFile(msg)
237+ // If pasted text has more than 2 newlines, treat it as a file attachment.
238− if errors.Is(err, errNotAFile) {
238+ if strings.Count(msg.Content, "\n") > 2 {
239− m.textarea, cmd = m.textarea.Update(msg)
239+ content := []byte(msg.Content)
240− return m, cmd
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+ })
241 }
254 }
255+
256+ // Try to parse as a file path.
257+ content, path, err := filepathToFile(msg.Content)
242 if err != nil {
258 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
244 }
262 }
245
263
246 if len(content) > maxAttachmentSize {
264 if len(content) > maxAttachmentSize {
@@ -257,7 +275,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
257 if !attachment.IsText() && !attachment.IsImage() {
275 if !attachment.IsText() && !attachment.IsImage() {
258 return m, util.ReportWarn("Invalid file content type: " + mimeType)
276 return m, util.ReportWarn("Invalid file content type: " + mimeType)
259 }
277 }
260− m.textarea.InsertString(attachment.FileName)
261 return m, util.CmdHandler(filepicker.FilePickedMsg{
278 return m, util.CmdHandler(filepicker.FilePickedMsg{
262 Attachment: attachment,
279 Attachment: attachment,
263 })
280 })
@@ -628,33 +645,21 @@ func New(app *app.App) Editor {
628
645
629var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
646var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
630
647
631−var errNotAFile = errors.New("not a file")
648+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
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−}
645
649
646−func contentToFile(content []byte) ([]byte, string, error) {
650+func (m *editorCmp) pasteIdx() int {
647− f, err := os.CreateTemp("", "paste_*.txt")
651+ result := 0
648− if err != nil {
652+ for _, at := range m.attachments {
649− return nil, "", err
653+ found := pasteRE.FindStringSubmatch(at.FileName)
650− }
654+ if len(found) == 0 {
651− if _, err := f.Write(content); err != nil {
655+ continue
652− return nil, "", err
653− }
656+ }
657+ idx, err := strconv.Atoi(found[1])
654− if err := f.Close(); err != nil {
658+ if err == nil {
655− return nil, "", err
659+ result = max(result, idx)
660+ }
656 }
661 }
657− return content, f.Name(), nil
662+ return result + 1
658}
663}
659
664
660func filepathToFile(name string) ([]byte, string, error) {
665func filepathToFile(name string) ([]byte, string, error) {
internal/tui/components/chat/messages/messages.go+5−3
@@ -223,8 +223,10 @@ func (m *messageCmp) renderAssistantMessage() string {
223// message content and any attached files with appropriate icons.
223// message content and any attached files with appropriate icons.
224func (m *messageCmp) renderUserMessage() string {
224func (m *messageCmp) renderUserMessage() string {
225 t := styles.CurrentTheme()
225 t := styles.CurrentTheme()
226− parts := []string{
226+ var parts []string
227+
227− m.toMarkdown(m.message.Content().String()),
228+ if s := m.message.Content().String(); s != "" {
229+ parts = append(parts, m.toMarkdown(s))
228 }
230 }
229
231
230 attachmentStyle := t.S().Base.
232 attachmentStyle := t.S().Base.
@@ -256,7 +258,7 @@ func (m *messageCmp) renderUserMessage() string {
256 }
258 }
257
259
258 if len(attachments) > 0 {
260 if len(attachments) > 0 {
259− parts = append(parts, "", strings.Join(attachments, ""))
261+ parts = append(parts, strings.Join(attachments, ""))
260 }
262 }
261
263
262 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
264 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)