@@ -2,6 +2,7 @@ package editor
6 "math/rand"
7 "math/rand"
@@ -29,6 +30,7 @@ import (
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/util"
32 "github.com/charmbracelet/crush/internal/tui/util"
33+ "github.com/charmbracelet/x/ansi"
34type Editor interface {
36type Editor interface {
@@ -84,10 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
89− maxFileResults = 25
89+const maxFileResults = 25
92type OpenEditorMsg struct {
91type OpenEditorMsg struct {
93 Text string
92 Text string
@@ -145,14 +144,14 @@ func (m *editorCmp) send() tea.Cmd {
145 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
144 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
149 attachments := m.attachments
147 attachments := m.attachments
152 if value == "" {
149 if value == "" {
153 return nil
150 return nil
156 // Change the placeholder when sending a new message.
155 // Change the placeholder when sending a new message.
157 m.randomizePlaceholders()
156 m.randomizePlaceholders()
@@ -176,9 +175,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
176 case tea.WindowSizeMsg:
175 case tea.WindowSizeMsg:
177 return m, m.repositionCompletions
176 return m, m.repositionCompletions
178 case filepicker.FilePickedMsg:
177 case filepicker.FilePickedMsg:
179− if len(m.attachments) >= maxAttachments {
180− return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
182 m.attachments = append(m.attachments, msg.Attachment)
178 m.attachments = append(m.attachments, msg.Attachment)
183 return m, nil
179 return m, nil
184 case completions.CompletionsOpenedMsg:
180 case completions.CompletionsOpenedMsg:
@@ -206,6 +202,17 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
206 m.currentQuery = ""
202 m.currentQuery = ""
207 m.completionsStartIndex = 0
203 m.completionsStartIndex = 0
205+ content, err := os.ReadFile(item.Path)
207+ // if it fails, let the LLM handle it later.
210+ m.attachments = append(m.attachments, message.Attachment{
211+ FilePath: item.Path,
212+ FileName: filepath.Base(item.Path),
213+ MimeType: mimeOf(content),
211 case commands.OpenExternalEditorMsg:
218 case commands.OpenExternalEditorMsg:
@@ -217,39 +224,30 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
217 m.textarea.SetValue(msg.Text)
224 m.textarea.SetValue(msg.Text)
218 m.textarea.MoveToEnd()
225 m.textarea.MoveToEnd()
219 case tea.PasteMsg:
226 case tea.PasteMsg:
220− path := strings.ReplaceAll(msg.Content, "\\ ", " ")
227+ content, path, err := pasteToFile(msg)
221− // try to get an image
228+ if errors.Is(err, errNotAFile) {
222− path, err := filepath.Abs(strings.TrimSpace(path))
224 m.textarea, cmd = m.textarea.Update(msg)
229 m.textarea, cmd = m.textarea.Update(msg)
225 return m, cmd
230 return m, cmd
227− isAllowedType := false
232+ if err != nil {
228− for _, ext := range filepicker.AllowedTypes {
229− if strings.HasSuffix(path, ext) {
230− isAllowedType = true
235− m.textarea, cmd = m.textarea.Update(msg)
236− return m, cmd
233+ return m, util.ReportError(err)
238− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
235+
239− if tooBig {
236+ if len(content) > maxAttachmentSize {
240− m.textarea, cmd = m.textarea.Update(msg)
237+ return m, util.ReportWarn("File is too big (>5mb)")
244− content, err := os.ReadFile(path)
240+ mimeType := mimeOf(content)
245− if err != nil {
241+ attachment := message.Attachment{
246− m.textarea, cmd = m.textarea.Update(msg)
242+ FilePath: path,
247− return m, cmd
243+ FileName: filepath.Base(path),
249− mimeBufferSize := min(512, len(content))
247+ if !attachment.IsText() && !attachment.IsImage() {
250− mimeType := http.DetectContentType(content[:mimeBufferSize])
248+ return m, util.ReportWarn("Invalid file content type: " + mimeType)
251− fileName := filepath.Base(path)
249+ }
252− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
250+ m.textarea.InsertString(attachment.FileName)
253 return m, util.CmdHandler(filepicker.FilePickedMsg{
251 return m, util.CmdHandler(filepicker.FilePickedMsg{
254 Attachment: attachment,
252 Attachment: attachment,
@@ -427,18 +425,17 @@ func (m *editorCmp) View() string {
427 m.textarea.Placeholder = "Yolo mode!"
425 m.textarea.Placeholder = "Yolo mode!"
429 if len(m.attachments) == 0 {
427 if len(m.attachments) == 0 {
430− content := t.S().Base.Padding(1).Render(
428+ return t.S().Base.Padding(1).Render(
431 m.textarea.View(),
429 m.textarea.View(),
435− content := t.S().Base.Padding(0, 1, 1, 1).Render(
432+ return t.S().Base.Padding(0, 1, 1, 1).Render(
436− lipgloss.JoinVertical(lipgloss.Top,
433+ lipgloss.JoinVertical(
437 m.attachmentsContent(),
435 m.attachmentsContent(),
438 m.textarea.View(),
436 m.textarea.View(),
444func (m *editorCmp) SetSize(width, height int) tea.Cmd {
441func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -456,24 +453,45 @@ func (m *editorCmp) GetSize() (int, int) {
456func (m *editorCmp) attachmentsContent() string {
453func (m *editorCmp) attachmentsContent() string {
457 var styledAttachments []string
454 var styledAttachments []string
458 t := styles.CurrentTheme()
455 t := styles.CurrentTheme()
459− attachmentStyles := t.S().Base.
456+ attachmentStyle := t.S().Base.
460− MarginLeft(1).
457+ Padding(0, 1).
461 Background(t.FgMuted).
459 Background(t.FgMuted).
462− Foreground(t.FgBase)
460+ Foreground(t.FgBase).
462+ iconStyle := t.S().Base.
463+ Foreground(t.BgSubtle).
464+ Background(t.Green).
468+ rmStyle := t.S().Base.
472+ Foreground(t.FgBase).
463 for i, attachment := range m.attachments {
474 for i, attachment := range m.attachments {
465− if len(attachment.FileName) > 10 {
475+ filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
466− filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
476+ icon := styles.ImageIcon
467− } else {
477+ if attachment.IsText() {
468− filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
478+ icon = styles.TextIcon
470 if m.deleteMode {
480 if m.deleteMode {
481+ styledAttachments = append(
471− filename = fmt.Sprintf("%d%s", i, filename)
483+ rmStyle(fmt.Sprintf("%d", i)),
484+ attachmentStyle(filename),
473− styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
488+ styledAttachments = append(
491+ attachmentStyle(filename),
475− content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
494+ return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
479func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
497func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
@@ -597,3 +615,51 @@ func New(app *app.App) Editor {
619+var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
621+var errNotAFile = errors.New("not a file")
623+func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
624+ content, path, err := filepathToFile(msg.Content)
626+ return content, path, err
629+ if strings.Count(msg.Content, "\n") > 2 {
630+ return contentToFile([]byte(msg.Content))
633+ return nil, "", errNotAFile
636+func contentToFile(content []byte) ([]byte, string, error) {
637+ f, err := os.CreateTemp("", "paste_*.txt")
641+ if _, err := f.Write(content); err != nil {
644+ if err := f.Close(); err != nil {
647+ return content, f.Name(), nil
650+func filepathToFile(name string) ([]byte, string, error) {
651+ path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
655+ content, err := os.ReadFile(path)
659+ return content, path, nil
662+func mimeOf(content []byte) string {
663+ mimeBufferSize := min(512, len(content))
664+ return http.DetectContentType(content[:mimeBufferSize])