@@ -2,6 +2,8 @@ package model
22
33import (
44 "context"
5+ "errors"
6+ "fmt"
57 "image"
68 "math/rand"
79 "net/http"
@@ -21,9 +23,12 @@ import (
2123 "github.com/charmbracelet/crush/internal/config"
2224 "github.com/charmbracelet/crush/internal/history"
2325 "github.com/charmbracelet/crush/internal/message"
26+ "github.com/charmbracelet/crush/internal/permission"
2427 "github.com/charmbracelet/crush/internal/pubsub"
2528 "github.com/charmbracelet/crush/internal/session"
29+ "github.com/charmbracelet/crush/internal/tui/components/chat"
2630 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
31+ "github.com/charmbracelet/crush/internal/tui/util"
2732 "github.com/charmbracelet/crush/internal/ui/common"
2833 "github.com/charmbracelet/crush/internal/ui/dialog"
2934 "github.com/charmbracelet/crush/internal/ui/logo"
@@ -98,7 +103,7 @@ type UI struct {
98103 // Editor components
99104 textarea textarea.Model
100105
101− attachments []any // TODO: Implement attachments
106+ attachments []message.Attachment // TODO: Implement attachments
102107
103108 readyPlaceholder string
104109 workingPlaceholder string
@@ -199,23 +204,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
199204 break
200205 }
201206
202− // Build tool result map to link tool calls with their results
203− msgPtrs := make([]*message.Message, len(msgs))
204− for i := range msgs {
205− msgPtrs[i] = &msgs[i]
206− }
207− toolResultMap := BuildToolResultMap(msgPtrs)
208−
209− // Add messages to chat with linked tool results
210− items := make([]MessageItem, 0, len(msgs)*2)
211− for _, msg := range msgPtrs {
212− items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
207+ if cmd := m.handleMessageEvents(msgs...); cmd != nil {
208+ cmds = append(cmds, cmd)
213209 }
214−
215− m.chat.SetMessages(items...)
216−
217− m.chat.ScrollToBottom()
218− m.chat.SelectLast()
210+ case pubsub.Event[message.Message]:
211+ // TODO: Finish implementing me
212+ cmds = append(cmds, m.handleMessageEvents(msg.Payload))
219213 case pubsub.Event[history.File]:
220214 cmds = append(cmds, m.handleFileEvent(msg.Payload))
221215 case pubsub.Event[app.LSPEvent]:
@@ -343,24 +337,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
343337 return m, tea.Batch(cmds...)
344338}
345339
346−func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
347− var cmds []tea.Cmd
340+func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd {
341+ // Build tool result map to link tool calls with their results
342+ msgPtrs := make([]*message.Message, len(msgs))
343+ for i := range msgs {
344+ msgPtrs[i] = &msgs[i]
345+ }
346+ toolResultMap := BuildToolResultMap(msgPtrs)
348347
349− handleQuitKeys := func(msg tea.KeyPressMsg) bool {
350− switch {
351− case key.Matches(msg, m.keyMap.Quit):
352− if !m.dialog.ContainsDialog(dialog.QuitID) {
353− m.dialog.OpenDialog(dialog.NewQuit(m.com))
354− return true
355− }
356− }
357− return false
348+ // Add messages to chat with linked tool results
349+ items := make([]MessageItem, 0, len(msgs)*2)
350+ for _, msg := range msgPtrs {
351+ items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
352+ }
353+
354+ if m.session == nil || m.session.ID == "" {
355+ m.chat.SetMessages(items...)
356+ } else {
357+ m.chat.AppendMessages(items...)
358358 }
359+ m.chat.ScrollToBottom()
360+ m.chat.SelectLast()
361+
362+ return nil
363+}
364+
365+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
366+ var cmds []tea.Cmd
359367
360368 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
361− if handleQuitKeys(msg) {
362− return true
363− }
364369 switch {
365370 case key.Matches(msg, m.keyMap.Help):
366371 m.help.ShowAll = !m.help.ShowAll
@@ -386,13 +391,17 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
386391 return false
387392 }
388393
389− // Route all messages to dialog if one is open.
390− if m.dialog.HasDialogs() {
394+ if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
391395 // Always handle quit keys first
392− if handleQuitKeys(msg) {
393− return tea.Batch(cmds...)
396+ if cmd := m.openQuitDialog(); cmd != nil {
397+ cmds = append(cmds, cmd)
394398 }
395399
400+ return tea.Batch(cmds...)
401+ }
402+
403+ // Route all messages to dialog if one is open.
404+ if m.dialog.HasDialogs() {
396405 msg := m.dialog.Update(msg)
397406 if msg == nil {
398407 return tea.Batch(cmds...)
@@ -443,7 +452,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
443452 case uiFocusEditor:
444453 switch {
445454 case key.Matches(msg, m.keyMap.Editor.SendMessage):
446− // TODO: Implement me
455+ value := m.textarea.Value()
456+ if strings.HasSuffix(value, "\\") {
457+ // If the last character is a backslash, remove it and add a newline.
458+ m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
459+ break
460+ }
461+
462+ // Otherwise, send the message
463+ m.textarea.Reset()
464+
465+ value = strings.TrimSpace(value)
466+ if value == "exit" || value == "quit" {
467+ return m.openQuitDialog()
468+ }
469+
470+ attachments := m.attachments
471+ m.attachments = nil
472+ if len(value) == 0 {
473+ return nil
474+ }
475+
476+ m.randomizePlaceholders()
477+
478+ return m.sendMessage(value, attachments)
447479 case key.Matches(msg, m.keyMap.Tab):
448480 m.focus = uiFocusMain
449481 m.textarea.Blur()
@@ -1134,6 +1166,56 @@ func (m *UI) renderSidebarLogo(width int) {
11341166 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
11351167}
11361168
1169+// sendMessage sends a message with the given content and attachments.
1170+func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1171+ if m.session == nil {
1172+ return uiutil.ReportError(fmt.Errorf("no session selected"))
1173+ }
1174+ session := *m.session
1175+ var cmds []tea.Cmd
1176+ if m.session.ID == "" {
1177+ newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1178+ if err != nil {
1179+ return uiutil.ReportError(err)
1180+ }
1181+ session = newSession
1182+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
1183+ }
1184+ if m.com.App.AgentCoordinator == nil {
1185+ return util.ReportError(fmt.Errorf("coder agent is not initialized"))
1186+ }
1187+ m.chat.ScrollToBottom()
1188+ cmds = append(cmds, func() tea.Msg {
1189+ _, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, attachments...)
1190+ if err != nil {
1191+ isCancelErr := errors.Is(err, context.Canceled)
1192+ isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1193+ if isCancelErr || isPermissionErr {
1194+ return nil
1195+ }
1196+ return util.InfoMsg{
1197+ Type: util.InfoTypeError,
1198+ Msg: err.Error(),
1199+ }
1200+ }
1201+ return nil
1202+ })
1203+ return tea.Batch(cmds...)
1204+}
1205+
1206+// openQuitDialog opens the quit confirmation dialog.
1207+func (m *UI) openQuitDialog() tea.Cmd {
1208+ if m.dialog.ContainsDialog(dialog.QuitID) {
1209+ // Bring to front
1210+ m.dialog.BringToFront(dialog.QuitID)
1211+ return nil
1212+ }
1213+
1214+ quitDialog := dialog.NewQuit(m.com)
1215+ m.dialog.OpenDialog(quitDialog)
1216+ return nil
1217+}
1218+
11371219// openCommandsDialog opens the commands dialog.
11381220func (m *UI) openCommandsDialog() tea.Cmd {
11391221 if m.dialog.ContainsDialog(dialog.CommandsID) {