1package model
2
3import (
4 "context"
5 "image"
6 "math/rand"
7 "os"
8 "slices"
9 "strings"
10 "time"
11
12 "charm.land/bubbles/v2/help"
13 "charm.land/bubbles/v2/key"
14 "charm.land/bubbles/v2/textarea"
15 tea "charm.land/bubbletea/v2"
16 "charm.land/lipgloss/v2"
17 "github.com/charmbracelet/crush/internal/agent"
18 "github.com/charmbracelet/crush/internal/agent/tools"
19 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
20 "github.com/charmbracelet/crush/internal/app"
21 "github.com/charmbracelet/crush/internal/config"
22 "github.com/charmbracelet/crush/internal/history"
23 "github.com/charmbracelet/crush/internal/message"
24 "github.com/charmbracelet/crush/internal/pubsub"
25 "github.com/charmbracelet/crush/internal/session"
26 "github.com/charmbracelet/crush/internal/ui/chat"
27 "github.com/charmbracelet/crush/internal/ui/common"
28 "github.com/charmbracelet/crush/internal/ui/common/anim"
29 "github.com/charmbracelet/crush/internal/ui/dialog"
30 "github.com/charmbracelet/crush/internal/ui/logo"
31 "github.com/charmbracelet/crush/internal/ui/styles"
32 "github.com/charmbracelet/crush/internal/version"
33 uv "github.com/charmbracelet/ultraviolet"
34 "github.com/charmbracelet/ultraviolet/screen"
35)
36
37// uiFocusState represents the current focus state of the UI.
38type uiFocusState uint8
39
40// Possible uiFocusState values.
41const (
42 uiFocusNone uiFocusState = iota
43 uiFocusEditor
44 uiFocusMain
45)
46
47type uiState uint8
48
49// Possible uiState values.
50const (
51 uiConfigure uiState = iota
52 uiInitialize
53 uiLanding
54 uiChat
55 uiChatCompact
56)
57
58// sessionsLoadedMsg is a message indicating that sessions have been loaded.
59type sessionsLoadedMsg struct {
60 sessions []session.Session
61}
62
63type sessionLoadedMsg struct {
64 sess session.Session
65}
66
67type sessionFilesLoadedMsg struct {
68 files []SessionFile
69}
70
71// UI represents the main user interface model.
72type UI struct {
73 com *common.Common
74 session *session.Session
75 sessionFiles []SessionFile
76
77 // The width and height of the terminal in cells.
78 width int
79 height int
80 layout layout
81
82 focus uiFocusState
83 state uiState
84
85 keyMap KeyMap
86 keyenh tea.KeyboardEnhancementsMsg
87
88 dialog *dialog.Overlay
89 help help.Model
90
91 // header is the last cached header logo
92 header string
93
94 // sendProgressBar instructs the TUI to send progress bar updates to the
95 // terminal.
96 sendProgressBar bool
97
98 // QueryVersion instructs the TUI to query for the terminal version when it
99 // starts.
100 QueryVersion bool
101
102 // Editor components
103 textarea textarea.Model
104
105 attachments []any // TODO: Implement attachments
106
107 readyPlaceholder string
108 workingPlaceholder string
109
110 // Chat components
111 chat *chat.Chat
112
113 // onboarding state
114 onboarding struct {
115 yesInitializeSelected bool
116 }
117
118 // lsp
119 lspStates map[string]app.LSPClientInfo
120
121 // mcp
122 mcpStates map[string]mcp.ClientInfo
123
124 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
125 sidebarLogo string
126
127 // lastUserMessageTime tracks the timestamp of the last user message for
128 // calculating response duration.
129 lastUserMessageTime int64
130}
131
132// New creates a new instance of the [UI] model.
133func New(com *common.Common) *UI {
134 // Editor components
135 ta := textarea.New()
136 ta.SetStyles(com.Styles.TextArea)
137 ta.ShowLineNumbers = false
138 ta.CharLimit = -1
139 ta.SetVirtualCursor(false)
140 ta.Focus()
141
142 ch := chat.NewChat(com)
143
144 ui := &UI{
145 com: com,
146 dialog: dialog.NewOverlay(),
147 keyMap: DefaultKeyMap(),
148 help: help.New(),
149 focus: uiFocusNone,
150 state: uiConfigure,
151 textarea: ta,
152 chat: ch,
153 }
154
155 // set onboarding state defaults
156 ui.onboarding.yesInitializeSelected = true
157
158 // If no provider is configured show the user the provider list
159 if !com.Config().IsConfigured() {
160 ui.state = uiConfigure
161 // if the project needs initialization show the user the question
162 } else if n, _ := config.ProjectNeedsInitialization(); n {
163 ui.state = uiInitialize
164 // otherwise go to the landing UI
165 } else {
166 ui.state = uiLanding
167 ui.focus = uiFocusEditor
168 }
169
170 ui.setEditorPrompt()
171 ui.randomizePlaceholders()
172 ui.textarea.Placeholder = ui.readyPlaceholder
173 ui.help.Styles = com.Styles.Help
174
175 return ui
176}
177
178// Init initializes the UI model.
179func (m *UI) Init() tea.Cmd {
180 var cmds []tea.Cmd
181 if m.QueryVersion {
182 cmds = append(cmds, tea.RequestTerminalVersion)
183 }
184 return tea.Batch(cmds...)
185}
186
187// sessionLoadedDoneMsg indicates that session loading and message appending is
188// done.
189type sessionLoadedDoneMsg struct{}
190
191// Update handles updates to the UI model.
192func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
193 var cmds []tea.Cmd
194 switch msg := msg.(type) {
195 case tea.EnvMsg:
196 // Is this Windows Terminal?
197 if !m.sendProgressBar {
198 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
199 }
200 case sessionsLoadedMsg:
201 sessions := dialog.NewSessions(m.com, msg.sessions...)
202 sessions.SetSize(min(120, m.width-8), 30)
203 m.dialog.AddDialog(sessions)
204 case dialog.SessionSelectedMsg:
205 m.dialog.RemoveDialog(dialog.SessionDialogID)
206 cmds = append(cmds,
207 m.loadSession(msg.Session.ID),
208 m.loadSessionFiles(msg.Session.ID),
209 )
210 case sessionLoadedMsg:
211 m.state = uiChat
212 m.session = &msg.sess
213 // TODO: handle error.
214 msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
215
216 m.chat.SetMessages(m.convertChatMessages(msgs)...)
217
218 // Notify that session loading is done to scroll to bottom. This is
219 // needed because we need to draw the chat list first before we can
220 // scroll to bottom.
221 cmds = append(cmds, func() tea.Msg {
222 return sessionLoadedDoneMsg{}
223 })
224 case sessionLoadedDoneMsg:
225 m.chat.ScrollToBottom()
226 m.chat.SelectLast()
227 case sessionFilesLoadedMsg:
228 m.sessionFiles = msg.files
229 case pubsub.Event[history.File]:
230 cmds = append(cmds, m.handleFileEvent(msg.Payload))
231 case pubsub.Event[app.LSPEvent]:
232 m.lspStates = app.GetLSPStates()
233 case pubsub.Event[mcp.Event]:
234 m.mcpStates = mcp.GetStates()
235 case pubsub.Event[message.Message]:
236 cmds = append(cmds, m.handleMessageEvent(msg))
237 case anim.StepMsg:
238 // Forward animation updates to chat items.
239 cmds = append(cmds, m.chat.UpdateItems(msg))
240 case tea.TerminalVersionMsg:
241 termVersion := strings.ToLower(msg.Name)
242 // Only enable progress bar for the following terminals.
243 if !m.sendProgressBar {
244 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
245 }
246 return m, nil
247 case tea.WindowSizeMsg:
248 m.width, m.height = msg.Width, msg.Height
249 m.updateLayoutAndSize()
250 case tea.KeyboardEnhancementsMsg:
251 m.keyenh = msg
252 if msg.SupportsKeyDisambiguation() {
253 m.keyMap.Models.SetHelp("ctrl+m", "models")
254 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
255 }
256 case tea.MouseClickMsg:
257 switch m.state {
258 case uiChat:
259 x, y := msg.X, msg.Y
260 // Adjust for chat area position
261 x -= m.layout.main.Min.X
262 y -= m.layout.main.Min.Y
263 m.chat.HandleMouseDown(x, y)
264 }
265
266 case tea.MouseMotionMsg:
267 switch m.state {
268 case uiChat:
269 if msg.Y <= 0 {
270 m.chat.ScrollBy(-1)
271 if !m.chat.SelectedItemInView() {
272 m.chat.SelectPrev()
273 m.chat.ScrollToSelected()
274 }
275 } else if msg.Y >= m.chat.Height()-1 {
276 m.chat.ScrollBy(1)
277 if !m.chat.SelectedItemInView() {
278 m.chat.SelectNext()
279 m.chat.ScrollToSelected()
280 }
281 }
282
283 x, y := msg.X, msg.Y
284 // Adjust for chat area position
285 x -= m.layout.main.Min.X
286 y -= m.layout.main.Min.Y
287 m.chat.HandleMouseDrag(x, y)
288 }
289
290 case tea.MouseReleaseMsg:
291 switch m.state {
292 case uiChat:
293 x, y := msg.X, msg.Y
294 // Adjust for chat area position
295 x -= m.layout.main.Min.X
296 y -= m.layout.main.Min.Y
297 m.chat.HandleMouseUp(x, y)
298 }
299 case tea.MouseWheelMsg:
300 switch m.state {
301 case uiChat:
302 switch msg.Button {
303 case tea.MouseWheelUp:
304 m.chat.ScrollBy(-5)
305 if !m.chat.SelectedItemInView() {
306 m.chat.SelectPrev()
307 m.chat.ScrollToSelected()
308 }
309 case tea.MouseWheelDown:
310 m.chat.ScrollBy(5)
311 if !m.chat.SelectedItemInView() {
312 m.chat.SelectNext()
313 m.chat.ScrollToSelected()
314 }
315 }
316 }
317 case tea.KeyPressMsg:
318 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
319 }
320
321 // This logic gets triggered on any message type, but should it?
322 switch m.focus {
323 case uiFocusMain:
324 case uiFocusEditor:
325 // Textarea placeholder logic
326 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
327 m.textarea.Placeholder = m.workingPlaceholder
328 } else {
329 m.textarea.Placeholder = m.readyPlaceholder
330 }
331 if m.com.App.Permissions.SkipRequests() {
332 m.textarea.Placeholder = "Yolo mode!"
333 }
334 }
335
336 return m, tea.Batch(cmds...)
337}
338
339func (m *UI) loadSession(sessionID string) tea.Cmd {
340 return func() tea.Msg {
341 // TODO: handle error
342 session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
343 return sessionLoadedMsg{session}
344 }
345}
346
347func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
348 handleQuitKeys := func(msg tea.KeyPressMsg) bool {
349 switch {
350 case key.Matches(msg, m.keyMap.Quit):
351 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
352 m.dialog.AddDialog(dialog.NewQuit(m.com))
353 return true
354 }
355 }
356 return false
357 }
358
359 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
360 if handleQuitKeys(msg) {
361 return true
362 }
363 switch {
364 case key.Matches(msg, m.keyMap.Tab):
365 case key.Matches(msg, m.keyMap.Help):
366 m.help.ShowAll = !m.help.ShowAll
367 m.updateLayoutAndSize()
368 return true
369 case key.Matches(msg, m.keyMap.Commands):
370 // TODO: Implement me
371 case key.Matches(msg, m.keyMap.Models):
372 // TODO: Implement me
373 case key.Matches(msg, m.keyMap.Sessions):
374 if m.dialog.ContainsDialog(dialog.SessionDialogID) {
375 // Bring to front
376 m.dialog.BringToFront(dialog.SessionDialogID)
377 } else {
378 cmds = append(cmds, m.loadSessionsCmd)
379 }
380 return true
381 }
382 return false
383 }
384
385 if m.dialog.HasDialogs() {
386 // Always handle quit keys first
387 if handleQuitKeys(msg) {
388 return cmds
389 }
390
391 updatedDialog, cmd := m.dialog.Update(msg)
392 m.dialog = updatedDialog
393 cmds = append(cmds, cmd)
394 return cmds
395 }
396
397 switch m.state {
398 case uiChat:
399 switch {
400 case key.Matches(msg, m.keyMap.Chat.NewSession):
401 if m.com.App.AgentCoordinator == nil {
402 return cmds
403 }
404 if m.com.App.AgentCoordinator.IsBusy() {
405 // TODO: Show warning message once we have a message/toast system
406 return cmds
407 }
408 cmds = append(cmds, m.newSession())
409 case key.Matches(msg, m.keyMap.Tab):
410 if m.focus == uiFocusMain {
411 m.focus = uiFocusEditor
412 cmds = append(cmds, m.textarea.Focus())
413 m.chat.Blur()
414 } else {
415 m.focus = uiFocusMain
416 m.textarea.Blur()
417 m.chat.Focus()
418 m.chat.SetSelected(m.chat.Len() - 1)
419 }
420 case key.Matches(msg, m.keyMap.Chat.Up) && m.focus == uiFocusMain:
421 m.chat.ScrollBy(-1)
422 if !m.chat.SelectedItemInView() {
423 m.chat.SelectPrev()
424 m.chat.ScrollToSelected()
425 }
426 case key.Matches(msg, m.keyMap.Chat.Down) && m.focus == uiFocusMain:
427 m.chat.ScrollBy(1)
428 if !m.chat.SelectedItemInView() {
429 m.chat.SelectNext()
430 m.chat.ScrollToSelected()
431 }
432 case key.Matches(msg, m.keyMap.Chat.UpOneItem) && m.focus == uiFocusMain:
433 m.chat.SelectPrev()
434 m.chat.ScrollToSelected()
435 case key.Matches(msg, m.keyMap.Chat.DownOneItem) && m.focus == uiFocusMain:
436 m.chat.SelectNext()
437 m.chat.ScrollToSelected()
438 case key.Matches(msg, m.keyMap.Chat.HalfPageUp) && m.focus == uiFocusMain:
439 m.chat.ScrollBy(-m.chat.Height() / 2)
440 m.chat.SelectFirstInView()
441 case key.Matches(msg, m.keyMap.Chat.HalfPageDown) && m.focus == uiFocusMain:
442 m.chat.ScrollBy(m.chat.Height() / 2)
443 m.chat.SelectLastInView()
444 case key.Matches(msg, m.keyMap.Chat.PageUp) && m.focus == uiFocusMain:
445 m.chat.ScrollBy(-m.chat.Height())
446 m.chat.SelectFirstInView()
447 case key.Matches(msg, m.keyMap.Chat.PageDown) && m.focus == uiFocusMain:
448 m.chat.ScrollBy(m.chat.Height())
449 m.chat.SelectLastInView()
450 case key.Matches(msg, m.keyMap.Chat.Home) && m.focus == uiFocusMain:
451 m.chat.ScrollToTop()
452 m.chat.SelectFirst()
453 case key.Matches(msg, m.keyMap.Chat.End) && m.focus == uiFocusMain:
454 m.chat.ScrollToBottom()
455 m.chat.SelectLast()
456 default:
457 // Try to handle key press in focused item (for expansion, etc.)
458 if m.focus == uiFocusMain && m.chat.HandleKeyPress(msg) {
459 return cmds
460 }
461 handleGlobalKeys(msg)
462 }
463 default:
464 handleGlobalKeys(msg)
465 }
466
467 cmds = append(cmds, m.updateFocused(msg)...)
468 return cmds
469}
470
471// Draw implements [tea.Layer] and draws the UI model.
472func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
473 layout := generateLayout(m, area.Dx(), area.Dy())
474
475 if m.layout != layout {
476 m.layout = layout
477 m.updateSize()
478 }
479
480 // Clear the screen first
481 screen.Clear(scr)
482
483 switch m.state {
484 case uiConfigure:
485 header := uv.NewStyledString(m.header)
486 header.Draw(scr, layout.header)
487
488 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
489 Height(layout.main.Dy()).
490 Background(lipgloss.ANSIColor(rand.Intn(256))).
491 Render(" Configure ")
492 main := uv.NewStyledString(mainView)
493 main.Draw(scr, layout.main)
494
495 case uiInitialize:
496 header := uv.NewStyledString(m.header)
497 header.Draw(scr, layout.header)
498
499 main := uv.NewStyledString(m.initializeView())
500 main.Draw(scr, layout.main)
501
502 case uiLanding:
503 header := uv.NewStyledString(m.header)
504 header.Draw(scr, layout.header)
505 main := uv.NewStyledString(m.landingView())
506 main.Draw(scr, layout.main)
507
508 editor := uv.NewStyledString(m.textarea.View())
509 editor.Draw(scr, layout.editor)
510
511 case uiChat:
512 m.chat.Draw(scr, layout.main)
513
514 header := uv.NewStyledString(m.header)
515 header.Draw(scr, layout.header)
516 m.drawSidebar(scr, layout.sidebar)
517
518 editor := uv.NewStyledString(m.textarea.View())
519 editor.Draw(scr, layout.editor)
520
521 case uiChatCompact:
522 header := uv.NewStyledString(m.header)
523 header.Draw(scr, layout.header)
524
525 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
526 Height(layout.main.Dy()).
527 Background(lipgloss.ANSIColor(rand.Intn(256))).
528 Render(" Compact Chat Messages ")
529 main := uv.NewStyledString(mainView)
530 main.Draw(scr, layout.main)
531
532 editor := uv.NewStyledString(m.textarea.View())
533 editor.Draw(scr, layout.editor)
534 }
535
536 // Add help layer
537 help := uv.NewStyledString(m.help.View(m))
538 help.Draw(scr, layout.help)
539
540 // Debugging rendering (visually see when the tui rerenders)
541 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
542 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
543 debug := uv.NewStyledString(debugView.String())
544 debug.Draw(scr, image.Rectangle{
545 Min: image.Pt(4, 1),
546 Max: image.Pt(8, 3),
547 })
548 }
549
550 // This needs to come last to overlay on top of everything
551 if m.dialog.HasDialogs() {
552 dialogLayers := m.dialog.Layers()
553 layers := make([]*lipgloss.Layer, 0)
554 for _, layer := range dialogLayers {
555 if layer == nil {
556 continue
557 }
558 layerW, layerH := layer.Width(), layer.Height()
559 layerArea := common.CenterRect(area, layerW, layerH)
560 layers = append(layers, layer.X(layerArea.Min.X).Y(layerArea.Min.Y))
561 }
562
563 comp := lipgloss.NewCompositor(layers...)
564 comp.Draw(scr, area)
565 }
566}
567
568// Cursor returns the cursor position and properties for the UI model. It
569// returns nil if the cursor should not be shown.
570func (m *UI) Cursor() *tea.Cursor {
571 if m.layout.editor.Dy() <= 0 {
572 // Don't show cursor if editor is not visible
573 return nil
574 }
575 if m.focus == uiFocusEditor && m.textarea.Focused() {
576 cur := m.textarea.Cursor()
577 cur.X++ // Adjust for app margins
578 cur.Y += m.layout.editor.Min.Y
579 return cur
580 }
581 return nil
582}
583
584// View renders the UI model's view.
585func (m *UI) View() tea.View {
586 var v tea.View
587 v.AltScreen = true
588 v.BackgroundColor = m.com.Styles.Background
589 v.Cursor = m.Cursor()
590 v.MouseMode = tea.MouseModeCellMotion
591
592 canvas := uv.NewScreenBuffer(m.width, m.height)
593 m.Draw(canvas, canvas.Bounds())
594
595 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
596 contentLines := strings.Split(content, "\n")
597 for i, line := range contentLines {
598 // Trim trailing spaces for concise rendering
599 contentLines[i] = strings.TrimRight(line, " ")
600 }
601
602 content = strings.Join(contentLines, "\n")
603
604 v.Content = content
605 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
606 // HACK: use a random percentage to prevent ghostty from hiding it
607 // after a timeout.
608 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
609 }
610
611 return v
612}
613
614// ShortHelp implements [help.KeyMap].
615func (m *UI) ShortHelp() []key.Binding {
616 var binds []key.Binding
617 k := &m.keyMap
618
619 switch m.state {
620 case uiInitialize:
621 binds = append(binds, k.Quit)
622 default:
623 // TODO: other states
624 // if m.session == nil {
625 // no session selected
626 binds = append(binds,
627 k.Commands,
628 k.Models,
629 k.Editor.Newline,
630 k.Quit,
631 k.Help,
632 )
633 // }
634 // else {
635 // we have a session
636 // }
637
638 // switch m.state {
639 // case uiChat:
640 // case uiEdit:
641 // binds = append(binds,
642 // k.Editor.AddFile,
643 // k.Editor.SendMessage,
644 // k.Editor.OpenEditor,
645 // k.Editor.Newline,
646 // )
647 //
648 // if len(m.attachments) > 0 {
649 // binds = append(binds,
650 // k.Editor.AttachmentDeleteMode,
651 // k.Editor.DeleteAllAttachments,
652 // k.Editor.Escape,
653 // )
654 // }
655 // }
656 }
657
658 return binds
659}
660
661// FullHelp implements [help.KeyMap].
662func (m *UI) FullHelp() [][]key.Binding {
663 var binds [][]key.Binding
664 k := &m.keyMap
665 help := k.Help
666 help.SetHelp("ctrl+g", "less")
667
668 switch m.state {
669 case uiInitialize:
670 binds = append(binds,
671 []key.Binding{
672 k.Quit,
673 })
674 default:
675 if m.session == nil {
676 // no session selected
677 binds = append(binds,
678 []key.Binding{
679 k.Commands,
680 k.Models,
681 k.Sessions,
682 },
683 []key.Binding{
684 k.Editor.Newline,
685 k.Editor.AddImage,
686 k.Editor.MentionFile,
687 k.Editor.OpenEditor,
688 },
689 []key.Binding{
690 help,
691 },
692 )
693 }
694 // else {
695 // we have a session
696 // }
697 }
698
699 // switch m.state {
700 // case uiChat:
701 // case uiEdit:
702 // binds = append(binds, m.ShortHelp())
703 // }
704
705 return binds
706}
707
708// updateFocused updates the focused model (chat or editor) with the given message
709// and appends any resulting commands to the cmds slice.
710func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
711 switch m.state {
712 case uiConfigure:
713 return cmds
714 case uiInitialize:
715 return append(cmds, m.updateInitializeView(msg)...)
716 case uiChat, uiLanding, uiChatCompact:
717 switch m.focus {
718 case uiFocusMain:
719 case uiFocusEditor:
720 switch {
721 case key.Matches(msg, m.keyMap.Editor.SendMessage):
722 text := strings.TrimSpace(m.textarea.Value())
723 if text != "" {
724 cmds = append(cmds, m.sendMessage(text, nil))
725 }
726 return cmds
727 case key.Matches(msg, m.keyMap.Editor.Newline):
728 m.textarea.InsertRune('\n')
729 }
730
731 ta, cmd := m.textarea.Update(msg)
732 m.textarea = ta
733 cmds = append(cmds, cmd)
734 return cmds
735 }
736 }
737 return cmds
738}
739
740// updateLayoutAndSize updates the layout and sizes of UI components.
741func (m *UI) updateLayoutAndSize() {
742 m.layout = generateLayout(m, m.width, m.height)
743 m.updateSize()
744}
745
746// updateSize updates the sizes of UI components based on the current layout.
747func (m *UI) updateSize() {
748 // Set help width
749 m.help.SetWidth(m.layout.help.Dx())
750
751 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
752 m.textarea.SetWidth(m.layout.editor.Dx())
753 m.textarea.SetHeight(m.layout.editor.Dy())
754
755 // Handle different app states
756 switch m.state {
757 case uiConfigure, uiInitialize, uiLanding:
758 m.renderHeader(false, m.layout.header.Dx())
759
760 case uiChat:
761 m.renderSidebarLogo(m.layout.sidebar.Dx())
762
763 case uiChatCompact:
764 // TODO: set the width and heigh of the chat component
765 m.renderHeader(true, m.layout.header.Dx())
766 }
767}
768
769// generateLayout calculates the layout rectangles for all UI components based
770// on the current UI state and terminal dimensions.
771func generateLayout(m *UI, w, h int) layout {
772 // The screen area we're working with
773 area := image.Rect(0, 0, w, h)
774
775 // The help height
776 helpHeight := 1
777 // The editor height
778 editorHeight := 5
779 // The sidebar width
780 sidebarWidth := 30
781 // The header height
782 // TODO: handle compact
783 headerHeight := 4
784
785 var helpKeyMap help.KeyMap = m
786 if m.help.ShowAll {
787 for _, row := range helpKeyMap.FullHelp() {
788 helpHeight = max(helpHeight, len(row))
789 }
790 }
791
792 // Add app margins
793 appRect := area
794 appRect.Min.X += 1
795 appRect.Min.Y += 1
796 appRect.Max.X -= 1
797 appRect.Max.Y -= 1
798
799 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
800 // extra padding on left and right for these states
801 appRect.Min.X += 1
802 appRect.Max.X -= 1
803 }
804
805 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
806
807 layout := layout{
808 area: area,
809 help: helpRect,
810 }
811
812 // Handle different app states
813 switch m.state {
814 case uiConfigure, uiInitialize:
815 // Layout
816 //
817 // header
818 // ------
819 // main
820 // ------
821 // help
822
823 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
824 layout.header = headerRect
825 layout.main = mainRect
826
827 case uiLanding:
828 // Layout
829 //
830 // header
831 // ------
832 // main
833 // ------
834 // editor
835 // ------
836 // help
837 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
838 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
839 // Remove extra padding from editor (but keep it for header and main)
840 editorRect.Min.X -= 1
841 editorRect.Max.X += 1
842 layout.header = headerRect
843 layout.main = mainRect
844 layout.editor = editorRect
845
846 case uiChat:
847 // Layout
848 //
849 // ------|---
850 // main |
851 // ------| side
852 // editor|
853 // ----------
854 // help
855
856 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
857 // Add padding left
858 sideRect.Min.X += 1
859 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
860 mainRect.Max.X -= 1 // Add padding right
861 // Add bottom margin to main
862 mainRect.Max.Y -= 1
863 layout.sidebar = sideRect
864 layout.main = mainRect
865 layout.editor = editorRect
866
867 case uiChatCompact:
868 // Layout
869 //
870 // compact-header
871 // ------
872 // main
873 // ------
874 // editor
875 // ------
876 // help
877 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
878 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
879 layout.header = headerRect
880 layout.main = mainRect
881 layout.editor = editorRect
882 }
883
884 if !layout.editor.Empty() {
885 // Add editor margins 1 top and bottom
886 layout.editor.Min.Y += 1
887 layout.editor.Max.Y -= 1
888 }
889
890 return layout
891}
892
893// layout defines the positioning of UI elements.
894type layout struct {
895 // area is the overall available area.
896 area uv.Rectangle
897
898 // header is the header shown in special cases
899 // e.x when the sidebar is collapsed
900 // or when in the landing page
901 // or in init/config
902 header uv.Rectangle
903
904 // main is the area for the main pane. (e.x chat, configure, landing)
905 main uv.Rectangle
906
907 // editor is the area for the editor pane.
908 editor uv.Rectangle
909
910 // sidebar is the area for the sidebar.
911 sidebar uv.Rectangle
912
913 // help is the area for the help view.
914 help uv.Rectangle
915}
916
917// setEditorPrompt configures the textarea prompt function based on whether
918// yolo mode is enabled.
919func (m *UI) setEditorPrompt() {
920 if m.com.App.Permissions.SkipRequests() {
921 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
922 return
923 }
924 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
925}
926
927// normalPromptFunc returns the normal editor prompt style (" > " on first
928// line, "::: " on subsequent lines).
929func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
930 t := m.com.Styles
931 if info.LineNumber == 0 {
932 return " > "
933 }
934 if info.Focused {
935 return t.EditorPromptNormalFocused.Render()
936 }
937 return t.EditorPromptNormalBlurred.Render()
938}
939
940// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
941// and colored dots.
942func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
943 t := m.com.Styles
944 if info.LineNumber == 0 {
945 if info.Focused {
946 return t.EditorPromptYoloIconFocused.Render()
947 } else {
948 return t.EditorPromptYoloIconBlurred.Render()
949 }
950 }
951 if info.Focused {
952 return t.EditorPromptYoloDotsFocused.Render()
953 }
954 return t.EditorPromptYoloDotsBlurred.Render()
955}
956
957var readyPlaceholders = [...]string{
958 "Ready!",
959 "Ready...",
960 "Ready?",
961 "Ready for instructions",
962}
963
964var workingPlaceholders = [...]string{
965 "Working!",
966 "Working...",
967 "Brrrrr...",
968 "Prrrrrrrr...",
969 "Processing...",
970 "Thinking...",
971}
972
973// randomizePlaceholders selects random placeholder text for the textarea's
974// ready and working states.
975func (m *UI) randomizePlaceholders() {
976 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
977 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
978}
979
980// renderHeader renders and caches the header logo at the specified width.
981func (m *UI) renderHeader(compact bool, width int) {
982 // TODO: handle the compact case differently
983 m.header = renderLogo(m.com.Styles, compact, width)
984}
985
986// renderSidebarLogo renders and caches the sidebar logo at the specified
987// width.
988func (m *UI) renderSidebarLogo(width int) {
989 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
990}
991
992// loadSessionsCmd loads the list of sessions and returns a command that sends
993// a sessionFilesLoadedMsg when done.
994func (m *UI) loadSessionsCmd() tea.Msg {
995 allSessions, _ := m.com.App.Sessions.List(context.TODO())
996 return sessionsLoadedMsg{sessions: allSessions}
997}
998
999// convertChatMessages converts messages to chat message items
1000func (m *UI) convertChatMessages(msgs []message.Message) []chat.MessageItem {
1001 items := make([]chat.MessageItem, 0)
1002
1003 // Build tool result map for efficient lookup.
1004 toolResultMap := m.buildToolResultMap(msgs)
1005
1006 var lastUserMessageTime time.Time
1007
1008 for _, msg := range msgs {
1009 switch msg.Role {
1010 case message.User:
1011 lastUserMessageTime = time.Unix(msg.CreatedAt, 0)
1012 items = append(items, chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles))
1013 case message.Assistant:
1014 // Add assistant message and its tool calls.
1015 assistantItems := m.convertAssistantMessage(msg, toolResultMap)
1016 items = append(items, assistantItems...)
1017
1018 // Add section separator if assistant finished with EndTurn.
1019 if msg.FinishReason() == message.FinishReasonEndTurn {
1020 modelName := m.getModelName(msg)
1021 items = append(items, chat.NewSectionItem(msg, lastUserMessageTime, modelName, m.com.Styles))
1022 }
1023 }
1024 }
1025 return items
1026}
1027
1028// getModelName returns the display name for a model, or "Unknown Model" if not found.
1029func (m *UI) getModelName(msg message.Message) string {
1030 model := m.com.Config().GetModel(msg.Provider, msg.Model)
1031 if model == nil {
1032 return "Unknown Model"
1033 }
1034 return model.Name
1035}
1036
1037// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
1038func (m *UI) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
1039 toolResultMap := make(map[string]message.ToolResult)
1040 for _, msg := range messages {
1041 for _, tr := range msg.ToolResults() {
1042 toolResultMap[tr.ToolCallID] = tr
1043 }
1044 }
1045 return toolResultMap
1046}
1047
1048// convertAssistantMessage converts an assistant message and its tool calls to UI items.
1049func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []chat.MessageItem {
1050 var items []chat.MessageItem
1051
1052 // Add assistant text/thinking message if it has content.
1053 content := strings.TrimSpace(msg.Content().Text)
1054 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1055 isError := msg.FinishReason() == message.FinishReasonError
1056 isCancelled := msg.FinishReason() == message.FinishReasonCanceled
1057
1058 hasToolCalls := len(msg.ToolCalls()) > 0
1059 reasoning := msg.ReasoningContent()
1060
1061 // Show when: no tool calls yet, OR has content, OR has thinking, OR is in thinking state.
1062 shouldShow := !hasToolCalls || content != "" || thinking != "" || msg.IsThinking()
1063 if shouldShow || isError || isCancelled {
1064 var finish message.Finish
1065 if fp := msg.FinishPart(); fp != nil {
1066 finish = *fp
1067 }
1068
1069 items = append(items, chat.NewAssistantMessage(
1070 msg.ID,
1071 content,
1072 thinking,
1073 msg.IsFinished(),
1074 finish,
1075 hasToolCalls,
1076 msg.IsSummaryMessage,
1077 reasoning.StartedAt,
1078 reasoning.FinishedAt,
1079 m.com.Styles,
1080 ))
1081 }
1082
1083 // Add tool call items.
1084 for _, tc := range msg.ToolCalls() {
1085 ctx := m.buildToolCallContext(tc, msg, toolResultMap)
1086
1087 // Handle nested tool calls for agent/agentic_fetch.
1088 if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1089 ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
1090 }
1091
1092 items = append(items, chat.NewToolItem(ctx))
1093 }
1094
1095 return items
1096}
1097
1098// buildToolCallContext creates a ToolCallContext from a tool call and its result.
1099func (m *UI) buildToolCallContext(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) chat.ToolCallContext {
1100 ctx := chat.ToolCallContext{
1101 Call: tc,
1102 Styles: m.com.Styles,
1103 IsNested: false,
1104 Cancelled: msg.FinishReason() == message.FinishReasonCanceled,
1105 }
1106
1107 // Add tool result if available.
1108 if tr, ok := toolResultMap[tc.ID]; ok {
1109 ctx.Result = &tr
1110 }
1111
1112 // TODO: Add permission tracking when we have permission service.
1113 // ctx.PermissionRequested = ...
1114 // ctx.PermissionGranted = ...
1115
1116 return ctx
1117}
1118
1119// loadNestedToolCalls loads nested tool calls for agent/agentic_fetch tools.
1120func (m *UI) loadNestedToolCalls(msgID, toolCallID string) []chat.ToolCallContext {
1121 agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(msgID, toolCallID)
1122 nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
1123 if err != nil || len(nestedMsgs) == 0 {
1124 return nil
1125 }
1126
1127 // Build nested tool result map.
1128 nestedToolResultMap := m.buildToolResultMap(nestedMsgs)
1129
1130 var nestedContexts []chat.ToolCallContext
1131 for _, nestedMsg := range nestedMsgs {
1132 for _, nestedTC := range nestedMsg.ToolCalls() {
1133 ctx := chat.ToolCallContext{
1134 Call: nestedTC,
1135 Styles: m.com.Styles,
1136 IsNested: true,
1137 Cancelled: nestedMsg.FinishReason() == message.FinishReasonCanceled,
1138 }
1139
1140 if tr, ok := nestedToolResultMap[nestedTC.ID]; ok {
1141 ctx.Result = &tr
1142 }
1143
1144 nestedContexts = append(nestedContexts, ctx)
1145 }
1146 }
1147
1148 return nestedContexts
1149}
1150
1151// newSession clears the current session and resets the UI to a fresh state.
1152func (m *UI) newSession() tea.Cmd {
1153 if m.session == nil || m.session.ID == "" {
1154 return nil
1155 }
1156
1157 m.session = nil
1158 m.state = uiLanding
1159 m.focus = uiFocusEditor
1160 m.chat.SetMessages()
1161 m.sessionFiles = nil
1162 return m.textarea.Focus()
1163}
1164
1165// renderLogo renders the Crush logo with the given styles and dimensions.
1166func renderLogo(t *styles.Styles, compact bool, width int) string {
1167 return logo.Render(version.Version, compact, logo.Opts{
1168 FieldColor: t.LogoFieldColor,
1169 TitleColorA: t.LogoTitleColorA,
1170 TitleColorB: t.LogoTitleColorB,
1171 CharmColor: t.LogoCharmColor,
1172 VersionColor: t.LogoVersionColor,
1173 Width: width,
1174 })
1175}
1176
1177// -----------------------------------------------------------------------------
1178// Message Event Handling
1179// -----------------------------------------------------------------------------
1180
1181// handleMessageEvent processes pubsub message events (created/updated/deleted).
1182func (m *UI) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
1183 // Ignore events for other sessions.
1184 if m.session == nil || event.Payload.SessionID != m.session.ID {
1185 return m.handleChildSessionEvent(event)
1186 }
1187
1188 switch event.Type {
1189 case pubsub.CreatedEvent:
1190 if m.chat.GetMessage(event.Payload.ID) != nil {
1191 return nil // Already exists.
1192 }
1193 return m.handleNewMessage(event.Payload)
1194 case pubsub.UpdatedEvent:
1195 return m.handleUpdateMessage(event.Payload)
1196 case pubsub.DeletedEvent:
1197 m.chat.DeleteMessage(event.Payload.ID)
1198 }
1199 return nil
1200}
1201
1202// handleChildSessionEvent handles messages from child sessions (agent tools).
1203func (m *UI) handleChildSessionEvent(event pubsub.Event[message.Message]) tea.Cmd {
1204 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1205 return nil
1206 }
1207
1208 // Check if this is an agent tool session.
1209 childSessionID := event.Payload.SessionID
1210 parentMessageID, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1211 if !ok {
1212 return nil
1213 }
1214
1215 // Find and update the parent tool call with new nested calls.
1216 if tool := m.chat.GetToolItem(toolCallID); tool != nil {
1217 tool.SetNestedCalls(m.loadNestedToolCalls(parentMessageID, toolCallID))
1218 m.chat.InvalidateItem(toolCallID)
1219 }
1220 return nil
1221}
1222
1223// handleNewMessage routes new messages to appropriate handlers based on role.
1224func (m *UI) handleNewMessage(msg message.Message) tea.Cmd {
1225 switch msg.Role {
1226 case message.User:
1227 return m.handleNewUserMessage(msg)
1228 case message.Assistant:
1229 return m.handleNewAssistantMessage(msg)
1230 case message.Tool:
1231 return m.handleToolResultMessage(msg)
1232 }
1233 return nil
1234}
1235
1236// handleNewUserMessage adds a new user message to the chat.
1237func (m *UI) handleNewUserMessage(msg message.Message) tea.Cmd {
1238 m.lastUserMessageTime = msg.CreatedAt
1239 userItem := chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles)
1240 m.chat.AppendMessages(userItem)
1241 m.chat.ScrollToBottom()
1242 return nil
1243}
1244
1245// handleNewAssistantMessage adds a new assistant message and its tool calls.
1246func (m *UI) handleNewAssistantMessage(msg message.Message) tea.Cmd {
1247 var cmds []tea.Cmd
1248
1249 content := strings.TrimSpace(msg.Content().Text)
1250 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1251 hasToolCalls := len(msg.ToolCalls()) > 0
1252
1253 // Add assistant message if it has content to display.
1254 if m.shouldShowAssistantMessage(msg) {
1255 assistantItem := m.createAssistantItem(msg, content, thinking, hasToolCalls)
1256 m.chat.AppendMessages(assistantItem)
1257 cmds = append(cmds, assistantItem.InitAnimation())
1258 }
1259
1260 // Add tool call items.
1261 for _, tc := range msg.ToolCalls() {
1262 ctx := m.buildToolCallContext(tc, msg, nil)
1263 if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1264 ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
1265 }
1266 toolItem := chat.NewToolItem(ctx)
1267 m.chat.AppendMessages(toolItem)
1268 if animatable, ok := toolItem.(chat.Animatable); ok {
1269 cmds = append(cmds, animatable.InitAnimation())
1270 }
1271 }
1272
1273 m.chat.ScrollToBottom()
1274 return tea.Batch(cmds...)
1275}
1276
1277// handleUpdateMessage routes update messages based on role.
1278func (m *UI) handleUpdateMessage(msg message.Message) tea.Cmd {
1279 switch msg.Role {
1280 case message.Assistant:
1281 return m.handleUpdateAssistantMessage(msg)
1282 case message.Tool:
1283 return m.handleToolResultMessage(msg)
1284 }
1285 return nil
1286}
1287
1288// handleUpdateAssistantMessage updates existing assistant message and tool calls.
1289func (m *UI) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
1290 var cmds []tea.Cmd
1291
1292 content := strings.TrimSpace(msg.Content().Text)
1293 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1294 hasToolCalls := len(msg.ToolCalls()) > 0
1295 shouldShow := m.shouldShowAssistantMessage(msg)
1296
1297 // Update or create/delete assistant message item.
1298 existingItem := m.chat.GetMessage(msg.ID)
1299 if existingItem != nil {
1300 if shouldShow {
1301 // Update existing message.
1302 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1303 assistantItem.SetContent(content, thinking, msg.IsFinished(), msg.FinishPart(), hasToolCalls, msg.IsSummaryMessage, msg.ReasoningContent())
1304 m.chat.InvalidateItem(msg.ID)
1305 }
1306
1307 // Add section separator when assistant finishes with EndTurn.
1308 if msg.FinishReason() == message.FinishReasonEndTurn {
1309 modelName := m.getModelName(msg)
1310 sectionItem := chat.NewSectionItem(msg, time.Unix(m.lastUserMessageTime, 0), modelName, m.com.Styles)
1311 m.chat.AppendMessages(sectionItem)
1312 }
1313 } else if hasToolCalls && content == "" && thinking == "" {
1314 // Remove if it's now just tool calls with no content.
1315 m.chat.DeleteMessage(msg.ID)
1316 }
1317 }
1318
1319 // Update existing or add new tool calls.
1320 for _, tc := range msg.ToolCalls() {
1321 if tool := m.chat.GetToolItem(tc.ID); tool != nil {
1322 // Update existing tool call.
1323 tool.UpdateCall(tc)
1324 if msg.FinishReason() == message.FinishReasonCanceled {
1325 tool.SetCancelled()
1326 }
1327 if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1328 tool.SetNestedCalls(m.loadNestedToolCalls(msg.ID, tc.ID))
1329 }
1330 m.chat.InvalidateItem(tc.ID)
1331 } else {
1332 // Add new tool call.
1333 ctx := m.buildToolCallContext(tc, msg, nil)
1334 if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
1335 ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
1336 }
1337 toolItem := chat.NewToolItem(ctx)
1338 m.chat.AppendMessages(toolItem)
1339 if animatable, ok := toolItem.(chat.Animatable); ok {
1340 cmds = append(cmds, animatable.InitAnimation())
1341 }
1342 }
1343 }
1344
1345 return tea.Batch(cmds...)
1346}
1347
1348// handleToolResultMessage updates tool calls with their results.
1349func (m *UI) handleToolResultMessage(msg message.Message) tea.Cmd {
1350 for _, tr := range msg.ToolResults() {
1351 if tool := m.chat.GetToolItem(tr.ToolCallID); tool != nil {
1352 tool.SetResult(tr)
1353 m.chat.InvalidateItem(tr.ToolCallID)
1354 }
1355 }
1356 return nil
1357}
1358
1359// shouldShowAssistantMessage returns true if the message should be displayed.
1360func (m *UI) shouldShowAssistantMessage(msg message.Message) bool {
1361 hasToolCalls := len(msg.ToolCalls()) > 0
1362 content := strings.TrimSpace(msg.Content().Text)
1363 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
1364 return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking()
1365}
1366
1367// createAssistantItem creates a new assistant message item.
1368func (m *UI) createAssistantItem(msg message.Message, content, thinking string, hasToolCalls bool) *chat.AssistantMessageItem {
1369 var finish message.Finish
1370 if fp := msg.FinishPart(); fp != nil {
1371 finish = *fp
1372 }
1373 reasoning := msg.ReasoningContent()
1374
1375 return chat.NewAssistantMessage(
1376 msg.ID,
1377 content,
1378 thinking,
1379 msg.IsFinished(),
1380 finish,
1381 hasToolCalls,
1382 msg.IsSummaryMessage,
1383 reasoning.StartedAt,
1384 reasoning.FinishedAt,
1385 m.com.Styles,
1386 )
1387}
1388
1389// sendMessage sends a user message to the agent coordinator.
1390func (m *UI) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
1391 sess := m.session
1392
1393 // Create a new session if we don't have one.
1394 if sess == nil || sess.ID == "" {
1395 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1396 if err != nil {
1397 return nil
1398 }
1399 sess = &newSession
1400 m.session = sess
1401 m.state = uiChat
1402 }
1403
1404 // Check if the agent coordinator is available.
1405 if m.com.App.AgentCoordinator == nil {
1406 return nil
1407 }
1408
1409 // Clear the textarea.
1410 m.textarea.Reset()
1411 m.randomizePlaceholders()
1412
1413 // Run the agent in a goroutine.
1414 sessionID := sess.ID
1415 return func() tea.Msg {
1416 _, _ = m.com.App.AgentCoordinator.Run(context.Background(), sessionID, text, attachments...)
1417 return nil
1418 }
1419}