@@ -4,7 +4,10 @@ import (
6 "math/rand"
6 "math/rand"
@@ -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
58+type openEditorMsg struct {
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) {
307 case tea.KeyPressMsg:
315 case tea.KeyPressMsg:
308 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
316 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
318+ if cmd := m.handlePasteMsg(msg); cmd != nil {
319+ cmds = append(cmds, cmd)
322+ m.textarea.SetValue(msg.Text)
323+ m.textarea.MoveToEnd()
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) {
415 switch m.state {
430 switch m.state {
416− case uiChat:
431+ case uiConfigure:
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:
@@ -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..."))
449+ cmds = append(cmds, m.openEditor(m.textarea.Value()))
426− handleGlobalKeys(msg)
451+ if handleGlobalKeys(msg) {
452+ // Handle global keys first before passing to textarea.
456+ ta, cmd := m.textarea.Update(msg)
458+ cmds = append(cmds, cmd)
428 case uiFocusMain:
460 case uiFocusMain:
@@ -477,7 +509,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
477 handleGlobalKeys(msg)
509 handleGlobalKeys(msg)
480− cmds = append(cmds, m.updateFocused(msg)...)
481 return cmds
512 return cmds
@@ -724,32 +755,6 @@ func (m *UI) FullHelp() [][]key.Binding {
724 return binds
755 return binds
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) {
734− return append(cmds, m.updateInitializeView(msg)...)
735− case uiChat, uiLanding, uiChatCompact:
740− case key.Matches(msg, m.keyMap.Editor.Newline):
741− m.textarea.InsertRune('\n')
744− ta, cmd := m.textarea.Update(msg)
746− cmds = append(cmds, cmd)
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
935+func (m *UI) openEditor(value string) tea.Cmd {
936+ editor := os.Getenv("EDITOR")
938+ // Use platform-appropriate default editor
939+ if runtime.GOOS == "windows" {
946+ tmpfile, err := os.CreateTemp("", "msg_*.md")
948+ return uiutil.ReportError(err)
950+ defer tmpfile.Close() //nolint:errcheck
951+ if _, err := tmpfile.WriteString(value); err != nil {
952+ return uiutil.ReportError(err)
954+ cmdStr := editor + " " + tmpfile.Name()
955+ return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
957+ return uiutil.ReportError(err)
959+ content, err := os.ReadFile(tmpfile.Name())
961+ return uiutil.ReportError(err)
963+ if len(content) == 0 {
964+ return uiutil.ReportWarn("Message is empty")
966+ os.Remove(tmpfile.Name())
967+ return openEditorMsg{
968+ Text: strings.TrimSpace(string(content)),
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}
1099+// handlePasteMsg handles a paste message.
1100+func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1101+ if m.focus != uiFocusEditor {
1106+ path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1107+ // try to get an image
1108+ path, err := filepath.Abs(strings.TrimSpace(path))
1110+ m.textarea, cmd = m.textarea.Update(msg)
1113+ isAllowedType := false
1114+ for _, ext := range filepicker.AllowedTypes {
1115+ if strings.HasSuffix(path, ext) {
1116+ isAllowedType = true
1120+ if !isAllowedType {
1121+ m.textarea, cmd = m.textarea.Update(msg)
1124+ tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1126+ m.textarea, cmd = m.textarea.Update(msg)
1130+ content, err := os.ReadFile(path)
1132+ m.textarea, cmd = m.textarea.Update(msg)
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,
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{