on main help sign in
fix(ui): openEditor and handle pasted files in editor
7b267bcAyman Bagabas
internal/ui/model/ui.go+117−29
@@ -4,7 +4,10 @@ import (
4 "context"
4 "context"
5 "image"
5 "image"
6 "math/rand"
6 "math/rand"
7+ "net/http"
7 "os"
8 "os"
9+ "path/filepath"
10+ "runtime"
8 "slices"
11 "slices"
9 "strings"
12 "strings"
10
13
@@ -20,6 +23,7 @@ import (
20 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/message"
21 "github.com/charmbracelet/crush/internal/pubsub"
24 "github.com/charmbracelet/crush/internal/pubsub"
22 "github.com/charmbracelet/crush/internal/session"
25 "github.com/charmbracelet/crush/internal/session"
26+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/ui/common"
27 "github.com/charmbracelet/crush/internal/ui/common"
24 "github.com/charmbracelet/crush/internal/ui/dialog"
28 "github.com/charmbracelet/crush/internal/ui/dialog"
25 "github.com/charmbracelet/crush/internal/ui/logo"
29 "github.com/charmbracelet/crush/internal/ui/logo"
@@ -51,6 +55,10 @@ const (
51 uiChatCompact
55 uiChatCompact
52)
56)
53
57
58+type openEditorMsg struct {
59+ Text string
60+}
61+
54// listSessionsMsg is a message to list available sessions.
62// listSessionsMsg is a message to list available sessions.
55type listSessionsMsg struct {
63type listSessionsMsg struct {
56 sessions []session.Session
64 sessions []session.Session
@@ -306,6 +314,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
306 }
314 }
307 case tea.KeyPressMsg:
315 case tea.KeyPressMsg:
308 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
316 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()
309 }
324 }
310
325
311 // This logic gets triggered on any message type, but should it?
326 // 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) {
413 }
428 }
414
429
415 switch m.state {
430 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:
417 switch m.focus {
436 switch m.focus {
418 case uiFocusEditor:
437 case uiFocusEditor:
419 switch {
438 switch {
@@ -422,8 +441,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
422 m.textarea.Blur()
441 m.textarea.Blur()
423 m.chat.Focus()
442 m.chat.Focus()
424 m.chat.SetSelected(m.chat.Len() - 1)
443 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()))
425 default:
450 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)
427 }
459 }
428 case uiFocusMain:
460 case uiFocusMain:
429 switch {
461 switch {
@@ -477,7 +509,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
477 handleGlobalKeys(msg)
509 handleGlobalKeys(msg)
478 }
510 }
479
511
480− cmds = append(cmds, m.updateFocused(msg)...)
481 return cmds
512 return cmds
482}
513}
483
514
@@ -724,32 +755,6 @@ func (m *UI) FullHelp() [][]key.Binding {
724 return binds
755 return binds
725}
756}
726
757
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−
753// updateLayoutAndSize updates the layout and sizes of UI components.
758// updateLayoutAndSize updates the layout and sizes of UI components.
754func (m *UI) updateLayoutAndSize() {
759func (m *UI) updateLayoutAndSize() {
755 m.layout = generateLayout(m, m.width, m.height)
760 m.layout = generateLayout(m, m.width, m.height)
@@ -927,6 +932,44 @@ type layout struct {
927 help uv.Rectangle
932 help uv.Rectangle
928}
933}
929
934
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+
930// setEditorPrompt configures the textarea prompt function based on whether
973// setEditorPrompt configures the textarea prompt function based on whether
931// yolo mode is enabled.
974// yolo mode is enabled.
932func (m *UI) setEditorPrompt(yolo bool) {
975func (m *UI) setEditorPrompt(yolo bool) {
@@ -1053,6 +1096,51 @@ func (m *UI) listSessions() tea.Msg {
1053 return listSessionsMsg{sessions: allSessions}
1096 return listSessionsMsg{sessions: allSessions}
1054}
1097}
1055
1098
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+
1056// renderLogo renders the Crush logo with the given styles and dimensions.
1144// renderLogo renders the Crush logo with the given styles and dimensions.
1057func renderLogo(t *styles.Styles, compact bool, width int) string {
1145func renderLogo(t *styles.Styles, compact bool, width int) string {
1058 return logo.Render(version.Version, compact, logo.Opts{
1146 return logo.Render(version.Version, compact, logo.Opts{