fix(ui): openEditor and handle pasted files in editor

Ayman Bagabas created

Change summary

internal/ui/model/ui.go | 146 ++++++++++++++++++++++++++++++++++--------
1 file changed, 117 insertions(+), 29 deletions(-)

Detailed changes

internal/ui/model/ui.go 🔗

@@ -4,7 +4,10 @@ import (
 	"context"
 	"image"
 	"math/rand"
+	"net/http"
 	"os"
+	"path/filepath"
+	"runtime"
 	"slices"
 	"strings"
 
@@ -20,6 +23,7 @@ import (
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"github.com/charmbracelet/crush/internal/ui/logo"
@@ -51,6 +55,10 @@ const (
 	uiChatCompact
 )
 
+type openEditorMsg struct {
+	Text string
+}
+
 // listSessionsMsg is a message to list available sessions.
 type listSessionsMsg struct {
 	sessions []session.Session
@@ -306,6 +314,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case tea.KeyPressMsg:
 		cmds = append(cmds, m.handleKeyPressMsg(msg)...)
+	case tea.PasteMsg:
+		if cmd := m.handlePasteMsg(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case openEditorMsg:
+		m.textarea.SetValue(msg.Text)
+		m.textarea.MoveToEnd()
 	}
 
 	// This logic gets triggered on any message type, but should it?
@@ -413,7 +428,11 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	}
 
 	switch m.state {
-	case uiChat:
+	case uiConfigure:
+		return cmds
+	case uiInitialize:
+		return append(cmds, m.updateInitializeView(msg)...)
+	case uiChat, uiLanding, uiChatCompact:
 		switch m.focus {
 		case uiFocusEditor:
 			switch {
@@ -422,8 +441,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 				m.textarea.Blur()
 				m.chat.Focus()
 				m.chat.SetSelected(m.chat.Len() - 1)
+			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
+				if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+					break
+				}
+				cmds = append(cmds, m.openEditor(m.textarea.Value()))
 			default:
-				handleGlobalKeys(msg)
+				if handleGlobalKeys(msg) {
+					// Handle global keys first before passing to textarea.
+					break
+				}
+
+				ta, cmd := m.textarea.Update(msg)
+				m.textarea = ta
+				cmds = append(cmds, cmd)
 			}
 		case uiFocusMain:
 			switch {
@@ -477,7 +509,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 		handleGlobalKeys(msg)
 	}
 
-	cmds = append(cmds, m.updateFocused(msg)...)
 	return cmds
 }
 
@@ -724,32 +755,6 @@ func (m *UI) FullHelp() [][]key.Binding {
 	return binds
 }
 
-// updateFocused updates the focused model (chat or editor) with the given message
-// and appends any resulting commands to the cmds slice.
-func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
-	switch m.state {
-	case uiConfigure:
-		return cmds
-	case uiInitialize:
-		return append(cmds, m.updateInitializeView(msg)...)
-	case uiChat, uiLanding, uiChatCompact:
-		switch m.focus {
-		case uiFocusMain:
-		case uiFocusEditor:
-			switch {
-			case key.Matches(msg, m.keyMap.Editor.Newline):
-				m.textarea.InsertRune('\n')
-			}
-
-			ta, cmd := m.textarea.Update(msg)
-			m.textarea = ta
-			cmds = append(cmds, cmd)
-			return cmds
-		}
-	}
-	return cmds
-}
-
 // updateLayoutAndSize updates the layout and sizes of UI components.
 func (m *UI) updateLayoutAndSize() {
 	m.layout = generateLayout(m, m.width, m.height)
@@ -927,6 +932,44 @@ type layout struct {
 	help uv.Rectangle
 }
 
+func (m *UI) 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 uiutil.ReportError(err)
+	}
+	defer tmpfile.Close() //nolint:errcheck
+	if _, err := tmpfile.WriteString(value); err != nil {
+		return uiutil.ReportError(err)
+	}
+	cmdStr := editor + " " + tmpfile.Name()
+	return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		content, err := os.ReadFile(tmpfile.Name())
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		if len(content) == 0 {
+			return uiutil.ReportWarn("Message is empty")
+		}
+		os.Remove(tmpfile.Name())
+		return openEditorMsg{
+			Text: strings.TrimSpace(string(content)),
+		}
+	})
+}
+
 // setEditorPrompt configures the textarea prompt function based on whether
 // yolo mode is enabled.
 func (m *UI) setEditorPrompt(yolo bool) {
@@ -1053,6 +1096,51 @@ func (m *UI) listSessions() tea.Msg {
 	return listSessionsMsg{sessions: allSessions}
 }
 
+// handlePasteMsg handles a paste message.
+func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
+	if m.focus != uiFocusEditor {
+		return nil
+	}
+
+	var cmd tea.Cmd
+	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
+	// try to get an image
+	path, err := filepath.Abs(strings.TrimSpace(path))
+	if err != nil {
+		m.textarea, cmd = m.textarea.Update(msg)
+		return cmd
+	}
+	isAllowedType := false
+	for _, ext := range filepicker.AllowedTypes {
+		if strings.HasSuffix(path, ext) {
+			isAllowedType = true
+			break
+		}
+	}
+	if !isAllowedType {
+		m.textarea, cmd = m.textarea.Update(msg)
+		return cmd
+	}
+	tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
+	if tooBig {
+		m.textarea, cmd = m.textarea.Update(msg)
+		return cmd
+	}
+
+	content, err := os.ReadFile(path)
+	if err != nil {
+		m.textarea, cmd = m.textarea.Update(msg)
+		return cmd
+	}
+	mimeBufferSize := min(512, len(content))
+	mimeType := http.DetectContentType(content[:mimeBufferSize])
+	fileName := filepath.Base(path)
+	attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
+	return uiutil.CmdHandler(filepicker.FilePickedMsg{
+		Attachment: attachment,
+	})
+}
+
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
 	return logo.Render(version.Version, compact, logo.Opts{