@@ -4,7 +4,10 @@ import (
44 "context"
55 "image"
66 "math/rand"
7+ "net/http"
78 "os"
9+ "path/filepath"
10+ "runtime"
811 "slices"
912 "strings"
1013
@@ -20,6 +23,7 @@ import (
2023 "github.com/charmbracelet/crush/internal/message"
2124 "github.com/charmbracelet/crush/internal/pubsub"
2225 "github.com/charmbracelet/crush/internal/session"
26+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
2327 "github.com/charmbracelet/crush/internal/ui/common"
2428 "github.com/charmbracelet/crush/internal/ui/dialog"
2529 "github.com/charmbracelet/crush/internal/ui/logo"
@@ -51,6 +55,10 @@ const (
5155 uiChatCompact
5256)
5357
58+type openEditorMsg struct {
59+ Text string
60+}
61+
5462// listSessionsMsg is a message to list available sessions.
5563type listSessionsMsg struct {
5664 sessions []session.Session
@@ -306,6 +314,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
306314 }
307315 case tea.KeyPressMsg:
308316 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
317+ case tea.PasteMsg:
318+ if cmd := m.handlePasteMsg(msg); cmd != nil {
319+ cmds = append(cmds, cmd)
320+ }
321+ case openEditorMsg:
322+ m.textarea.SetValue(msg.Text)
323+ m.textarea.MoveToEnd()
309324 }
310325
311326 // 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) {
413428 }
414429
415430 switch m.state {
416− case uiChat:
431+ case uiConfigure:
432+ return cmds
433+ case uiInitialize:
434+ return append(cmds, m.updateInitializeView(msg)...)
435+ case uiChat, uiLanding, uiChatCompact:
417436 switch m.focus {
418437 case uiFocusEditor:
419438 switch {
@@ -422,8 +441,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
422441 m.textarea.Blur()
423442 m.chat.Focus()
424443 m.chat.SetSelected(m.chat.Len() - 1)
444+ case key.Matches(msg, m.keyMap.Editor.OpenEditor):
445+ if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
446+ cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
447+ break
448+ }
449+ cmds = append(cmds, m.openEditor(m.textarea.Value()))
425450 default:
426− handleGlobalKeys(msg)
451+ if handleGlobalKeys(msg) {
452+ // Handle global keys first before passing to textarea.
453+ break
454+ }
455+
456+ ta, cmd := m.textarea.Update(msg)
457+ m.textarea = ta
458+ cmds = append(cmds, cmd)
427459 }
428460 case uiFocusMain:
429461 switch {
@@ -477,7 +509,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
477509 handleGlobalKeys(msg)
478510 }
479511
480− cmds = append(cmds, m.updateFocused(msg)...)
481512 return cmds
482513}
483514
@@ -724,32 +755,6 @@ func (m *UI) FullHelp() [][]key.Binding {
724755 return binds
725756}
726757
727−// updateFocused updates the focused model (chat or editor) with the given message
728−// and appends any resulting commands to the cmds slice.
729−func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
730− switch m.state {
731− case uiConfigure:
732− return cmds
733− case uiInitialize:
734− return append(cmds, m.updateInitializeView(msg)...)
735− case uiChat, uiLanding, uiChatCompact:
736− switch m.focus {
737− case uiFocusMain:
738− case uiFocusEditor:
739− switch {
740− case key.Matches(msg, m.keyMap.Editor.Newline):
741− m.textarea.InsertRune('\n')
742− }
743−
744− ta, cmd := m.textarea.Update(msg)
745− m.textarea = ta
746− cmds = append(cmds, cmd)
747− return cmds
748− }
749− }
750− return cmds
751−}
752−
753758// updateLayoutAndSize updates the layout and sizes of UI components.
754759func (m *UI) updateLayoutAndSize() {
755760 m.layout = generateLayout(m, m.width, m.height)
@@ -927,6 +932,44 @@ type layout struct {
927932 help uv.Rectangle
928933}
929934
935+func (m *UI) openEditor(value string) tea.Cmd {
936+ editor := os.Getenv("EDITOR")
937+ if editor == "" {
938+ // Use platform-appropriate default editor
939+ if runtime.GOOS == "windows" {
940+ editor = "notepad"
941+ } else {
942+ editor = "nvim"
943+ }
944+ }
945+
946+ tmpfile, err := os.CreateTemp("", "msg_*.md")
947+ if err != nil {
948+ return uiutil.ReportError(err)
949+ }
950+ defer tmpfile.Close() //nolint:errcheck
951+ if _, err := tmpfile.WriteString(value); err != nil {
952+ return uiutil.ReportError(err)
953+ }
954+ cmdStr := editor + " " + tmpfile.Name()
955+ return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
956+ if err != nil {
957+ return uiutil.ReportError(err)
958+ }
959+ content, err := os.ReadFile(tmpfile.Name())
960+ if err != nil {
961+ return uiutil.ReportError(err)
962+ }
963+ if len(content) == 0 {
964+ return uiutil.ReportWarn("Message is empty")
965+ }
966+ os.Remove(tmpfile.Name())
967+ return openEditorMsg{
968+ Text: strings.TrimSpace(string(content)),
969+ }
970+ })
971+}
972+
930973// setEditorPrompt configures the textarea prompt function based on whether
931974// yolo mode is enabled.
932975func (m *UI) setEditorPrompt(yolo bool) {
@@ -1053,6 +1096,51 @@ func (m *UI) listSessions() tea.Msg {
10531096 return listSessionsMsg{sessions: allSessions}
10541097}
10551098
1099+// handlePasteMsg handles a paste message.
1100+func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1101+ if m.focus != uiFocusEditor {
1102+ return nil
1103+ }
1104+
1105+ var cmd tea.Cmd
1106+ path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1107+ // try to get an image
1108+ path, err := filepath.Abs(strings.TrimSpace(path))
1109+ if err != nil {
1110+ m.textarea, cmd = m.textarea.Update(msg)
1111+ return cmd
1112+ }
1113+ isAllowedType := false
1114+ for _, ext := range filepicker.AllowedTypes {
1115+ if strings.HasSuffix(path, ext) {
1116+ isAllowedType = true
1117+ break
1118+ }
1119+ }
1120+ if !isAllowedType {
1121+ m.textarea, cmd = m.textarea.Update(msg)
1122+ return cmd
1123+ }
1124+ tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1125+ if tooBig {
1126+ m.textarea, cmd = m.textarea.Update(msg)
1127+ return cmd
1128+ }
1129+
1130+ content, err := os.ReadFile(path)
1131+ if err != nil {
1132+ m.textarea, cmd = m.textarea.Update(msg)
1133+ return cmd
1134+ }
1135+ mimeBufferSize := min(512, len(content))
1136+ mimeType := http.DetectContentType(content[:mimeBufferSize])
1137+ fileName := filepath.Base(path)
1138+ attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1139+ return uiutil.CmdHandler(filepicker.FilePickedMsg{
1140+ Attachment: attachment,
1141+ })
1142+}
1143+
10561144// renderLogo renders the Crush logo with the given styles and dimensions.
10571145func renderLogo(t *styles.Styles, compact bool, width int) string {
10581146 return logo.Render(version.Version, compact, logo.Opts{