feat(ui): wip: basic chat message sending

Ayman Bagabas created

Change summary

internal/ui/chat/messages.go   |   9 ++
internal/ui/dialog/commands.go |  11 --
internal/ui/dialog/quit.go     |  10 +
internal/ui/model/ui.go        | 154 +++++++++++++++++++++++++++--------
4 files changed, 137 insertions(+), 47 deletions(-)

Detailed changes

internal/ui/chat/messages.go 🔗

@@ -0,0 +1,9 @@
+package chat
+
+import "github.com/charmbracelet/crush/internal/message"
+
+// SendMsg represents a message to send a chat message.
+type SendMsg struct {
+	Text        string
+	Attachments []message.Attachment
+}

internal/ui/dialog/commands.go 🔗

@@ -15,7 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -26,13 +26,6 @@ import (
 // CommandsID is the identifier for the commands dialog.
 const CommandsID = "commands"
 
-// SendMsg represents a message to send a chat message.
-// TODO: Move to chat package?
-type SendMsg struct {
-	Text        string
-	Attachments []message.Attachment
-}
-
 // Commands represents a dialog that shows available commands.
 type Commands struct {
 	com    *common.Common
@@ -451,7 +444,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 				if err != nil {
 					return uiutil.ReportError(err)
 				}
-				return uiutil.CmdHandler(SendMsg{
+				return uiutil.CmdHandler(chat.SendMsg{
 					Text: initPrompt,
 				})
 			},

internal/ui/dialog/quit.go 🔗

@@ -20,7 +20,8 @@ type Quit struct {
 		Yes,
 		No,
 		Tab,
-		Close key.Binding
+		Close,
+		Quit key.Binding
 	}
 }
 
@@ -51,6 +52,10 @@ func NewQuit(com *common.Common) *Quit {
 		key.WithHelp("tab", "switch options"),
 	)
 	q.keyMap.Close = CloseKey
+	q.keyMap.Quit = key.NewBinding(
+		key.WithKeys("ctrl+c"),
+		key.WithHelp("ctrl+c", "quit"),
+	)
 	return q
 }
 
@@ -64,11 +69,12 @@ func (q *Quit) Update(msg tea.Msg) tea.Msg {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch {
+		case key.Matches(msg, q.keyMap.Quit):
+			return QuitMsg{}
 		case key.Matches(msg, q.keyMap.Close):
 			return CloseMsg{}
 		case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
 			q.selectedNo = !q.selectedNo
-			return CloseMsg{}
 		case key.Matches(msg, q.keyMap.EnterSpace):
 			if !q.selectedNo {
 				return QuitMsg{}

internal/ui/model/ui.go 🔗

@@ -2,6 +2,8 @@ package model
 
 import (
 	"context"
+	"errors"
+	"fmt"
 	"image"
 	"math/rand"
 	"net/http"
@@ -21,9 +23,12 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"github.com/charmbracelet/crush/internal/ui/logo"
@@ -98,7 +103,7 @@ type UI struct {
 	// Editor components
 	textarea textarea.Model
 
-	attachments []any // TODO: Implement attachments
+	attachments []message.Attachment // TODO: Implement attachments
 
 	readyPlaceholder   string
 	workingPlaceholder string
@@ -199,23 +204,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			break
 		}
 
-		// Build tool result map to link tool calls with their results
-		msgPtrs := make([]*message.Message, len(msgs))
-		for i := range msgs {
-			msgPtrs[i] = &msgs[i]
-		}
-		toolResultMap := BuildToolResultMap(msgPtrs)
-
-		// Add messages to chat with linked tool results
-		items := make([]MessageItem, 0, len(msgs)*2)
-		for _, msg := range msgPtrs {
-			items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
+		if cmd := m.handleMessageEvents(msgs...); cmd != nil {
+			cmds = append(cmds, cmd)
 		}
-
-		m.chat.SetMessages(items...)
-
-		m.chat.ScrollToBottom()
-		m.chat.SelectLast()
+	case pubsub.Event[message.Message]:
+		// TODO: Finish implementing me
+		cmds = append(cmds, m.handleMessageEvents(msg.Payload))
 	case pubsub.Event[history.File]:
 		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 	case pubsub.Event[app.LSPEvent]:
@@ -343,24 +337,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
-	var cmds []tea.Cmd
+func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd {
+	// Build tool result map to link tool calls with their results
+	msgPtrs := make([]*message.Message, len(msgs))
+	for i := range msgs {
+		msgPtrs[i] = &msgs[i]
+	}
+	toolResultMap := BuildToolResultMap(msgPtrs)
 
-	handleQuitKeys := func(msg tea.KeyPressMsg) bool {
-		switch {
-		case key.Matches(msg, m.keyMap.Quit):
-			if !m.dialog.ContainsDialog(dialog.QuitID) {
-				m.dialog.OpenDialog(dialog.NewQuit(m.com))
-				return true
-			}
-		}
-		return false
+	// Add messages to chat with linked tool results
+	items := make([]MessageItem, 0, len(msgs)*2)
+	for _, msg := range msgPtrs {
+		items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
+	}
+
+	if m.session == nil || m.session.ID == "" {
+		m.chat.SetMessages(items...)
+	} else {
+		m.chat.AppendMessages(items...)
 	}
+	m.chat.ScrollToBottom()
+	m.chat.SelectLast()
+
+	return nil
+}
+
+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+	var cmds []tea.Cmd
 
 	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
-		if handleQuitKeys(msg) {
-			return true
-		}
 		switch {
 		case key.Matches(msg, m.keyMap.Help):
 			m.help.ShowAll = !m.help.ShowAll
@@ -386,13 +391,17 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		return false
 	}
 
-	// Route all messages to dialog if one is open.
-	if m.dialog.HasDialogs() {
+	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
 		// Always handle quit keys first
-		if handleQuitKeys(msg) {
-			return tea.Batch(cmds...)
+		if cmd := m.openQuitDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
 		}
 
+		return tea.Batch(cmds...)
+	}
+
+	// Route all messages to dialog if one is open.
+	if m.dialog.HasDialogs() {
 		msg := m.dialog.Update(msg)
 		if msg == nil {
 			return tea.Batch(cmds...)
@@ -443,7 +452,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		case uiFocusEditor:
 			switch {
 			case key.Matches(msg, m.keyMap.Editor.SendMessage):
-				// TODO: Implement me
+				value := m.textarea.Value()
+				if strings.HasSuffix(value, "\\") {
+					// If the last character is a backslash, remove it and add a newline.
+					m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
+					break
+				}
+
+				// Otherwise, send the message
+				m.textarea.Reset()
+
+				value = strings.TrimSpace(value)
+				if value == "exit" || value == "quit" {
+					return m.openQuitDialog()
+				}
+
+				attachments := m.attachments
+				m.attachments = nil
+				if len(value) == 0 {
+					return nil
+				}
+
+				m.randomizePlaceholders()
+
+				return m.sendMessage(value, attachments)
 			case key.Matches(msg, m.keyMap.Tab):
 				m.focus = uiFocusMain
 				m.textarea.Blur()
@@ -1134,6 +1166,56 @@ func (m *UI) renderSidebarLogo(width int) {
 	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
 }
 
+// sendMessage sends a message with the given content and attachments.
+func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
+	if m.session == nil {
+		return uiutil.ReportError(fmt.Errorf("no session selected"))
+	}
+	session := *m.session
+	var cmds []tea.Cmd
+	if m.session.ID == "" {
+		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		session = newSession
+		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+	}
+	if m.com.App.AgentCoordinator == nil {
+		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
+	}
+	m.chat.ScrollToBottom()
+	cmds = append(cmds, func() tea.Msg {
+		_, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, attachments...)
+		if err != nil {
+			isCancelErr := errors.Is(err, context.Canceled)
+			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+			if isCancelErr || isPermissionErr {
+				return nil
+			}
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  err.Error(),
+			}
+		}
+		return nil
+	})
+	return tea.Batch(cmds...)
+}
+
+// openQuitDialog opens the quit confirmation dialog.
+func (m *UI) openQuitDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.QuitID) {
+		// Bring to front
+		m.dialog.BringToFront(dialog.QuitID)
+		return nil
+	}
+
+	quitDialog := dialog.NewQuit(m.com)
+	m.dialog.OpenDialog(quitDialog)
+	return nil
+}
+
 // openCommandsDialog opens the commands dialog.
 func (m *UI) openCommandsDialog() tea.Cmd {
 	if m.dialog.ContainsDialog(dialog.CommandsID) {