on main help sign in
feat(ui): wip: basic chat message sending
cd81f94Ayman Bagabas
internal/ui/chat/messages.gonew file+9
@@ -0,0 +1,9 @@
1+package chat
2+
3+import "github.com/charmbracelet/crush/internal/message"
4+
5+// SendMsg represents a message to send a chat message.
6+type SendMsg struct {
7+ Text string
8+ Attachments []message.Attachment
9+}
internal/ui/dialog/commands.go+2−9
@@ -15,7 +15,7 @@ import (
15 "github.com/charmbracelet/crush/internal/agent"
15 "github.com/charmbracelet/crush/internal/agent"
16 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/csync"
18− "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/ui/chat"
19 "github.com/charmbracelet/crush/internal/ui/common"
19 "github.com/charmbracelet/crush/internal/ui/common"
20 "github.com/charmbracelet/crush/internal/ui/list"
20 "github.com/charmbracelet/crush/internal/ui/list"
21 "github.com/charmbracelet/crush/internal/ui/styles"
21 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -26,13 +26,6 @@ import (
26// CommandsID is the identifier for the commands dialog.
26// CommandsID is the identifier for the commands dialog.
27const CommandsID = "commands"
27const CommandsID = "commands"
28
28
29−// SendMsg represents a message to send a chat message.
30−// TODO: Move to chat package?
31−type SendMsg struct {
32− Text string
33− Attachments []message.Attachment
34−}
35−
36// Commands represents a dialog that shows available commands.
29// Commands represents a dialog that shows available commands.
37type Commands struct {
30type Commands struct {
38 com *common.Common
31 com *common.Common
@@ -451,7 +444,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
451 if err != nil {
444 if err != nil {
452 return uiutil.ReportError(err)
445 return uiutil.ReportError(err)
453 }
446 }
454− return uiutil.CmdHandler(SendMsg{
447+ return uiutil.CmdHandler(chat.SendMsg{
455 Text: initPrompt,
448 Text: initPrompt,
456 })
449 })
457 },
450 },
internal/ui/dialog/quit.go+8−2
@@ -20,7 +20,8 @@ type Quit struct {
20 Yes,
20 Yes,
21 No,
21 No,
22 Tab,
22 Tab,
23+ Close,
23− Close key.Binding
24+ Quit key.Binding
24 }
25 }
25}
26}
26
27
@@ -51,6 +52,10 @@ func NewQuit(com *common.Common) *Quit {
51 key.WithHelp("tab", "switch options"),
52 key.WithHelp("tab", "switch options"),
52 )
53 )
53 q.keyMap.Close = CloseKey
54 q.keyMap.Close = CloseKey
55+ q.keyMap.Quit = key.NewBinding(
56+ key.WithKeys("ctrl+c"),
57+ key.WithHelp("ctrl+c", "quit"),
58+ )
54 return q
59 return q
55}
60}
56
61
@@ -64,11 +69,12 @@ func (q *Quit) Update(msg tea.Msg) tea.Msg {
64 switch msg := msg.(type) {
69 switch msg := msg.(type) {
65 case tea.KeyPressMsg:
70 case tea.KeyPressMsg:
66 switch {
71 switch {
72+ case key.Matches(msg, q.keyMap.Quit):
73+ return QuitMsg{}
67 case key.Matches(msg, q.keyMap.Close):
74 case key.Matches(msg, q.keyMap.Close):
68 return CloseMsg{}
75 return CloseMsg{}
69 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
76 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
70 q.selectedNo = !q.selectedNo
77 q.selectedNo = !q.selectedNo
71− return CloseMsg{}
72 case key.Matches(msg, q.keyMap.EnterSpace):
78 case key.Matches(msg, q.keyMap.EnterSpace):
73 if !q.selectedNo {
79 if !q.selectedNo {
74 return QuitMsg{}
80 return QuitMsg{}
internal/ui/model/ui.go+118−36
@@ -2,6 +2,8 @@ package model
2
2
3import (
3import (
4 "context"
4 "context"
5+ "errors"
6+ "fmt"
5 "image"
7 "image"
6 "math/rand"
8 "math/rand"
7 "net/http"
9 "net/http"
@@ -21,9 +23,12 @@ import (
21 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
22 "github.com/charmbracelet/crush/internal/history"
24 "github.com/charmbracelet/crush/internal/history"
23 "github.com/charmbracelet/crush/internal/message"
25 "github.com/charmbracelet/crush/internal/message"
26+ "github.com/charmbracelet/crush/internal/permission"
24 "github.com/charmbracelet/crush/internal/pubsub"
27 "github.com/charmbracelet/crush/internal/pubsub"
25 "github.com/charmbracelet/crush/internal/session"
28 "github.com/charmbracelet/crush/internal/session"
29+ "github.com/charmbracelet/crush/internal/tui/components/chat"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
31+ "github.com/charmbracelet/crush/internal/tui/util"
27 "github.com/charmbracelet/crush/internal/ui/common"
32 "github.com/charmbracelet/crush/internal/ui/common"
28 "github.com/charmbracelet/crush/internal/ui/dialog"
33 "github.com/charmbracelet/crush/internal/ui/dialog"
29 "github.com/charmbracelet/crush/internal/ui/logo"
34 "github.com/charmbracelet/crush/internal/ui/logo"
@@ -98,7 +103,7 @@ type UI struct {
98 // Editor components
103 // Editor components
99 textarea textarea.Model
104 textarea textarea.Model
100
105
101− attachments []any // TODO: Implement attachments
106+ attachments []message.Attachment // TODO: Implement attachments
102
107
103 readyPlaceholder string
108 readyPlaceholder string
104 workingPlaceholder string
109 workingPlaceholder string
@@ -199,23 +204,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
199 break
204 break
200 }
205 }
201
206
202− // Build tool result map to link tool calls with their results
207+ if cmd := m.handleMessageEvents(msgs...); cmd != nil {
203− msgPtrs := make([]*message.Message, len(msgs))
208+ cmds = append(cmds, cmd)
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)...)
213 }
209 }
214−
210+ case pubsub.Event[message.Message]:
215− m.chat.SetMessages(items...)
211+ // TODO: Finish implementing me
216−
212+ cmds = append(cmds, m.handleMessageEvents(msg.Payload))
217− m.chat.ScrollToBottom()
218− m.chat.SelectLast()
219 case pubsub.Event[history.File]:
213 case pubsub.Event[history.File]:
220 cmds = append(cmds, m.handleFileEvent(msg.Payload))
214 cmds = append(cmds, m.handleFileEvent(msg.Payload))
221 case pubsub.Event[app.LSPEvent]:
215 case pubsub.Event[app.LSPEvent]:
@@ -343,24 +337,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
343 return m, tea.Batch(cmds...)
337 return m, tea.Batch(cmds...)
344}
338}
345
339
346−func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
340+func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd {
347− var cmds []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)
348
347
349− handleQuitKeys := func(msg tea.KeyPressMsg) bool {
348+ // Add messages to chat with linked tool results
350− switch {
349+ items := make([]MessageItem, 0, len(msgs)*2)
351− case key.Matches(msg, m.keyMap.Quit):
350+ for _, msg := range msgPtrs {
352− if !m.dialog.ContainsDialog(dialog.QuitID) {
351+ items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
353− m.dialog.OpenDialog(dialog.NewQuit(m.com))
352+ }
354− return true
353+
355− }
354+ if m.session == nil || m.session.ID == "" {
356− }
355+ m.chat.SetMessages(items...)
357− return false
356+ } else {
357+ m.chat.AppendMessages(items...)
358 }
358 }
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
359
367
360 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
368 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
361− if handleQuitKeys(msg) {
362− return true
363− }
364 switch {
369 switch {
365 case key.Matches(msg, m.keyMap.Help):
370 case key.Matches(msg, m.keyMap.Help):
366 m.help.ShowAll = !m.help.ShowAll
371 m.help.ShowAll = !m.help.ShowAll
@@ -386,13 +391,17 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
386 return false
391 return false
387 }
392 }
388
393
389− // Route all messages to dialog if one is open.
394+ if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
390− if m.dialog.HasDialogs() {
391 // Always handle quit keys first
395 // Always handle quit keys first
392− if handleQuitKeys(msg) {
396+ if cmd := m.openQuitDialog(); cmd != nil {
393− return tea.Batch(cmds...)
397+ cmds = append(cmds, cmd)
394 }
398 }
395
399
400+ return tea.Batch(cmds...)
401+ }
402+
403+ // Route all messages to dialog if one is open.
404+ if m.dialog.HasDialogs() {
396 msg := m.dialog.Update(msg)
405 msg := m.dialog.Update(msg)
397 if msg == nil {
406 if msg == nil {
398 return tea.Batch(cmds...)
407 return tea.Batch(cmds...)
@@ -443,7 +452,30 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
443 case uiFocusEditor:
452 case uiFocusEditor:
444 switch {
453 switch {
445 case key.Matches(msg, m.keyMap.Editor.SendMessage):
454 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)
447 case key.Matches(msg, m.keyMap.Tab):
479 case key.Matches(msg, m.keyMap.Tab):
448 m.focus = uiFocusMain
480 m.focus = uiFocusMain
449 m.textarea.Blur()
481 m.textarea.Blur()
@@ -1134,6 +1166,56 @@ func (m *UI) renderSidebarLogo(width int) {
1134 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1166 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1135}
1167}
1136
1168
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+
1137// openCommandsDialog opens the commands dialog.
1219// openCommandsDialog opens the commands dialog.
1138func (m *UI) openCommandsDialog() tea.Cmd {
1220func (m *UI) openCommandsDialog() tea.Cmd {
1139 if m.dialog.ContainsDialog(dialog.CommandsID) {
1221 if m.dialog.ContainsDialog(dialog.CommandsID) {