1package model
2
3import (
4 "bytes"
5 "cmp"
6 "context"
7 "errors"
8 "fmt"
9 "image"
10 "log/slog"
11 "math/rand"
12 "net/http"
13 "os"
14 "path/filepath"
15 "regexp"
16 "slices"
17 "strconv"
18 "strings"
19 "time"
20
21 "charm.land/bubbles/v2/help"
22 "charm.land/bubbles/v2/key"
23 "charm.land/bubbles/v2/spinner"
24 "charm.land/bubbles/v2/textarea"
25 tea "charm.land/bubbletea/v2"
26 "charm.land/catwalk/pkg/catwalk"
27 "charm.land/lipgloss/v2"
28 "github.com/charmbracelet/crush/internal/agent/notify"
29 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
30 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
31 "github.com/charmbracelet/crush/internal/app"
32 "github.com/charmbracelet/crush/internal/commands"
33 "github.com/charmbracelet/crush/internal/config"
34 "github.com/charmbracelet/crush/internal/fsext"
35 "github.com/charmbracelet/crush/internal/history"
36 "github.com/charmbracelet/crush/internal/home"
37 "github.com/charmbracelet/crush/internal/message"
38 "github.com/charmbracelet/crush/internal/permission"
39 "github.com/charmbracelet/crush/internal/pubsub"
40 "github.com/charmbracelet/crush/internal/session"
41 "github.com/charmbracelet/crush/internal/ui/anim"
42 "github.com/charmbracelet/crush/internal/ui/attachments"
43 "github.com/charmbracelet/crush/internal/ui/chat"
44 "github.com/charmbracelet/crush/internal/ui/common"
45 "github.com/charmbracelet/crush/internal/ui/completions"
46 "github.com/charmbracelet/crush/internal/ui/dialog"
47 fimage "github.com/charmbracelet/crush/internal/ui/image"
48 "github.com/charmbracelet/crush/internal/ui/logo"
49 "github.com/charmbracelet/crush/internal/ui/notification"
50 "github.com/charmbracelet/crush/internal/ui/styles"
51 "github.com/charmbracelet/crush/internal/ui/util"
52 "github.com/charmbracelet/crush/internal/version"
53 uv "github.com/charmbracelet/ultraviolet"
54 "github.com/charmbracelet/ultraviolet/layout"
55 "github.com/charmbracelet/ultraviolet/screen"
56 "github.com/charmbracelet/x/editor"
57)
58
59// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
60// wheel event occurs.
61const MouseScrollThreshold = 5
62
63// Compact mode breakpoints.
64const (
65 compactModeWidthBreakpoint = 120
66 compactModeHeightBreakpoint = 30
67)
68
69// If pasted text has more than 10 newlines, treat it as a file attachment.
70const pasteLinesThreshold = 10
71
72// If pasted text has more than 1000 columns, treat it as a file attachment.
73const pasteColsThreshold = 1000
74
75// Session details panel max height.
76const sessionDetailsMaxHeight = 20
77
78// uiFocusState represents the current focus state of the UI.
79type uiFocusState uint8
80
81// Possible uiFocusState values.
82const (
83 uiFocusNone uiFocusState = iota
84 uiFocusEditor
85 uiFocusMain
86)
87
88type uiState uint8
89
90// Possible uiState values.
91const (
92 uiOnboarding uiState = iota
93 uiInitialize
94 uiLanding
95 uiChat
96)
97
98type openEditorMsg struct {
99 Text string
100}
101
102type (
103 // cancelTimerExpiredMsg is sent when the cancel timer expires.
104 cancelTimerExpiredMsg struct{}
105 // userCommandsLoadedMsg is sent when user commands are loaded.
106 userCommandsLoadedMsg struct {
107 Commands []commands.CustomCommand
108 }
109 // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
110 mcpPromptsLoadedMsg struct {
111 Prompts []commands.MCPPrompt
112 }
113 // mcpStateChangedMsg is sent when there is a change in MCP client states.
114 mcpStateChangedMsg struct {
115 states map[string]mcp.ClientInfo
116 }
117 // sendMessageMsg is sent to send a message.
118 // currently only used for mcp prompts.
119 sendMessageMsg struct {
120 Content string
121 Attachments []message.Attachment
122 }
123
124 // closeDialogMsg is sent to close the current dialog.
125 closeDialogMsg struct{}
126
127 // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
128 copyChatHighlightMsg struct{}
129
130 // sessionFilesUpdatesMsg is sent when the files for this session have been updated
131 sessionFilesUpdatesMsg struct {
132 sessionFiles []SessionFile
133 }
134)
135
136// UI represents the main user interface model.
137type UI struct {
138 com *common.Common
139 session *session.Session
140 sessionFiles []SessionFile
141
142 // keeps track of read files while we don't have a session id
143 sessionFileReads []string
144
145 // initialSessionID is set when loading a specific session on startup.
146 initialSessionID string
147 // continueLastSession is set to continue the most recent session on startup.
148 continueLastSession bool
149
150 lastUserMessageTime int64
151
152 // The width and height of the terminal in cells.
153 width int
154 height int
155 layout uiLayout
156
157 isTransparent bool
158
159 focus uiFocusState
160 state uiState
161
162 keyMap KeyMap
163 keyenh tea.KeyboardEnhancementsMsg
164
165 dialog *dialog.Overlay
166 status *Status
167
168 // isCanceling tracks whether the user has pressed escape once to cancel.
169 isCanceling bool
170
171 header *header
172
173 // sendProgressBar instructs the TUI to send progress bar updates to the
174 // terminal.
175 sendProgressBar bool
176 progressBarEnabled bool
177
178 // caps hold different terminal capabilities that we query for.
179 caps common.Capabilities
180
181 // Editor components
182 textarea textarea.Model
183
184 // Attachment list
185 attachments *attachments.Attachments
186
187 readyPlaceholder string
188 workingPlaceholder string
189
190 // Completions state
191 completions *completions.Completions
192 completionsOpen bool
193 completionsStartIndex int
194 completionsQuery string
195 completionsPositionStart image.Point // x,y where user typed '@'
196
197 // Chat components
198 chat *Chat
199
200 // onboarding state
201 onboarding struct {
202 yesInitializeSelected bool
203 }
204
205 // lsp
206 lspStates map[string]app.LSPClientInfo
207
208 // mcp
209 mcpStates map[string]mcp.ClientInfo
210
211 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
212 sidebarLogo string
213
214 // Notification state
215 notifyBackend notification.Backend
216 notifyWindowFocused bool
217 // custom commands & mcp commands
218 customCommands []commands.CustomCommand
219 mcpPrompts []commands.MCPPrompt
220
221 // forceCompactMode tracks whether compact mode is forced by user toggle
222 forceCompactMode bool
223
224 // isCompact tracks whether we're currently in compact layout mode (either
225 // by user toggle or auto-switch based on window size)
226 isCompact bool
227
228 // detailsOpen tracks whether the details panel is open (in compact mode)
229 detailsOpen bool
230
231 // pills state
232 pillsExpanded bool
233 focusedPillSection pillSection
234 promptQueue int
235 pillsView string
236
237 // Todo spinner
238 todoSpinner spinner.Model
239 todoIsSpinning bool
240
241 // mouse highlighting related state
242 lastClickTime time.Time
243
244 // Prompt history for up/down navigation through previous messages.
245 promptHistory struct {
246 messages []string
247 index int
248 draft string
249 }
250}
251
252// New creates a new instance of the [UI] model.
253func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
254 // Editor components
255 ta := textarea.New()
256 ta.SetStyles(com.Styles.TextArea)
257 ta.ShowLineNumbers = false
258 ta.CharLimit = -1
259 ta.SetVirtualCursor(false)
260 ta.Focus()
261
262 ch := NewChat(com)
263
264 keyMap := DefaultKeyMap()
265
266 // Completions component
267 comp := completions.New(
268 com.Styles.Completions.Normal,
269 com.Styles.Completions.Focused,
270 com.Styles.Completions.Match,
271 )
272
273 todoSpinner := spinner.New(
274 spinner.WithSpinner(spinner.MiniDot),
275 spinner.WithStyle(com.Styles.Pills.TodoSpinner),
276 )
277
278 // Attachments component
279 attachments := attachments.New(
280 attachments.NewRenderer(
281 com.Styles.Attachments.Normal,
282 com.Styles.Attachments.Deleting,
283 com.Styles.Attachments.Image,
284 com.Styles.Attachments.Text,
285 ),
286 attachments.Keymap{
287 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
288 DeleteAll: keyMap.Editor.DeleteAllAttachments,
289 Escape: keyMap.Editor.Escape,
290 },
291 )
292
293 header := newHeader(com)
294
295 ui := &UI{
296 com: com,
297 dialog: dialog.NewOverlay(),
298 keyMap: keyMap,
299 textarea: ta,
300 chat: ch,
301 header: header,
302 completions: comp,
303 attachments: attachments,
304 todoSpinner: todoSpinner,
305 lspStates: make(map[string]app.LSPClientInfo),
306 mcpStates: make(map[string]mcp.ClientInfo),
307 notifyBackend: notification.NoopBackend{},
308 notifyWindowFocused: true,
309 initialSessionID: initialSessionID,
310 continueLastSession: continueLast,
311 }
312
313 status := NewStatus(com, ui)
314
315 ui.setEditorPrompt(false)
316 ui.randomizePlaceholders()
317 ui.textarea.Placeholder = ui.readyPlaceholder
318 ui.status = status
319
320 // Initialize compact mode from config
321 ui.forceCompactMode = com.Config().Options.TUI.CompactMode
322
323 // set onboarding state defaults
324 ui.onboarding.yesInitializeSelected = true
325
326 desiredState := uiLanding
327 desiredFocus := uiFocusEditor
328 if !com.Config().IsConfigured() {
329 desiredState = uiOnboarding
330 } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
331 desiredState = uiInitialize
332 }
333
334 // set initial state
335 ui.setState(desiredState, desiredFocus)
336
337 opts := com.Config().Options
338
339 // disable indeterminate progress bar
340 ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
341 // enable transparent mode
342 ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
343
344 return ui
345}
346
347// Init initializes the UI model.
348func (m *UI) Init() tea.Cmd {
349 var cmds []tea.Cmd
350 if m.state == uiOnboarding {
351 if cmd := m.openModelsDialog(); cmd != nil {
352 cmds = append(cmds, cmd)
353 }
354 }
355 // load the user commands async
356 cmds = append(cmds, m.loadCustomCommands())
357 // load prompt history async
358 cmds = append(cmds, m.loadPromptHistory())
359 // load initial session if specified
360 if cmd := m.loadInitialSession(); cmd != nil {
361 cmds = append(cmds, cmd)
362 }
363 return tea.Batch(cmds...)
364}
365
366// loadInitialSession loads the initial session if one was specified on startup.
367func (m *UI) loadInitialSession() tea.Cmd {
368 switch {
369 case m.state != uiLanding:
370 // Only load if we're in landing state (i.e., fully configured)
371 return nil
372 case m.initialSessionID != "":
373 return m.loadSession(m.initialSessionID)
374 case m.continueLastSession:
375 return func() tea.Msg {
376 sess, err := m.com.App.Sessions.GetLast(context.Background())
377 if err != nil {
378 return nil
379 }
380 return m.loadSession(sess.ID)()
381 }
382 default:
383 return nil
384 }
385}
386
387// sendNotification returns a command that sends a notification if allowed by policy.
388func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
389 if !m.shouldSendNotification() {
390 return nil
391 }
392
393 backend := m.notifyBackend
394 return func() tea.Msg {
395 if err := backend.Send(n); err != nil {
396 slog.Error("Failed to send notification", "error", err)
397 }
398 return nil
399 }
400}
401
402// shouldSendNotification returns true if notifications should be sent based on
403// current state. Focus reporting must be supported, window must not focused,
404// and notifications must not be disabled in config.
405func (m *UI) shouldSendNotification() bool {
406 cfg := m.com.Config()
407 if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
408 return false
409 }
410 return m.caps.ReportFocusEvents && !m.notifyWindowFocused
411}
412
413// setState changes the UI state and focus.
414func (m *UI) setState(state uiState, focus uiFocusState) {
415 if state == uiLanding {
416 // Always turn off compact mode when going to landing
417 m.isCompact = false
418 }
419 m.state = state
420 m.focus = focus
421 // Changing the state may change layout, so update it.
422 m.updateLayoutAndSize()
423}
424
425// loadCustomCommands loads the custom commands asynchronously.
426func (m *UI) loadCustomCommands() tea.Cmd {
427 return func() tea.Msg {
428 customCommands, err := commands.LoadCustomCommands(m.com.Config())
429 if err != nil {
430 slog.Error("Failed to load custom commands", "error", err)
431 }
432 return userCommandsLoadedMsg{Commands: customCommands}
433 }
434}
435
436// loadMCPrompts loads the MCP prompts asynchronously.
437func (m *UI) loadMCPrompts() tea.Msg {
438 prompts, err := commands.LoadMCPPrompts()
439 if err != nil {
440 slog.Error("Failed to load MCP prompts", "error", err)
441 }
442 if prompts == nil {
443 // flag them as loaded even if there is none or an error
444 prompts = []commands.MCPPrompt{}
445 }
446 return mcpPromptsLoadedMsg{Prompts: prompts}
447}
448
449// Update handles updates to the UI model.
450func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
451 var cmds []tea.Cmd
452 if m.hasSession() && m.isAgentBusy() {
453 queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
454 if queueSize != m.promptQueue {
455 m.promptQueue = queueSize
456 m.updateLayoutAndSize()
457 }
458 }
459 // Update terminal capabilities
460 m.caps.Update(msg)
461 switch msg := msg.(type) {
462 case tea.EnvMsg:
463 // Is this Windows Terminal?
464 if !m.sendProgressBar {
465 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
466 }
467 cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
468 case tea.ModeReportMsg:
469 if m.caps.ReportFocusEvents {
470 m.notifyBackend = notification.NewNativeBackend(notification.Icon)
471 }
472 case tea.FocusMsg:
473 m.notifyWindowFocused = true
474 case tea.BlurMsg:
475 m.notifyWindowFocused = false
476 case pubsub.Event[notify.Notification]:
477 if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
478 cmds = append(cmds, cmd)
479 }
480 case loadSessionMsg:
481 if m.forceCompactMode {
482 m.isCompact = true
483 }
484 m.setState(uiChat, m.focus)
485 m.session = msg.session
486 m.sessionFiles = msg.files
487 cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
488 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
489 if err != nil {
490 cmds = append(cmds, util.ReportError(err))
491 break
492 }
493 if cmd := m.setSessionMessages(msgs); cmd != nil {
494 cmds = append(cmds, cmd)
495 }
496 if hasInProgressTodo(m.session.Todos) {
497 // only start spinner if there is an in-progress todo
498 if m.isAgentBusy() {
499 m.todoIsSpinning = true
500 cmds = append(cmds, m.todoSpinner.Tick)
501 }
502 m.updateLayoutAndSize()
503 }
504 // Reload prompt history for the new session.
505 m.historyReset()
506 cmds = append(cmds, m.loadPromptHistory())
507 m.updateLayoutAndSize()
508
509 case sessionFilesUpdatesMsg:
510 m.sessionFiles = msg.sessionFiles
511 var paths []string
512 for _, f := range msg.sessionFiles {
513 paths = append(paths, f.LatestVersion.Path)
514 }
515 cmds = append(cmds, m.startLSPs(paths))
516
517 case sendMessageMsg:
518 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
519
520 case userCommandsLoadedMsg:
521 m.customCommands = msg.Commands
522 dia := m.dialog.Dialog(dialog.CommandsID)
523 if dia == nil {
524 break
525 }
526
527 commands, ok := dia.(*dialog.Commands)
528 if ok {
529 commands.SetCustomCommands(m.customCommands)
530 }
531
532 case mcpStateChangedMsg:
533 m.mcpStates = msg.states
534 case mcpPromptsLoadedMsg:
535 m.mcpPrompts = msg.Prompts
536 dia := m.dialog.Dialog(dialog.CommandsID)
537 if dia == nil {
538 break
539 }
540
541 commands, ok := dia.(*dialog.Commands)
542 if ok {
543 commands.SetMCPPrompts(m.mcpPrompts)
544 }
545
546 case promptHistoryLoadedMsg:
547 m.promptHistory.messages = msg.messages
548 m.promptHistory.index = -1
549 m.promptHistory.draft = ""
550
551 case closeDialogMsg:
552 m.dialog.CloseFrontDialog()
553
554 case pubsub.Event[session.Session]:
555 if msg.Type == pubsub.DeletedEvent {
556 if m.session != nil && m.session.ID == msg.Payload.ID {
557 if cmd := m.newSession(); cmd != nil {
558 cmds = append(cmds, cmd)
559 }
560 }
561 break
562 }
563 if m.session != nil && msg.Payload.ID == m.session.ID {
564 prevHasInProgress := hasInProgressTodo(m.session.Todos)
565 m.session = &msg.Payload
566 if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
567 m.todoIsSpinning = true
568 cmds = append(cmds, m.todoSpinner.Tick)
569 m.updateLayoutAndSize()
570 }
571 }
572 case pubsub.Event[message.Message]:
573 // Check if this is a child session message for an agent tool.
574 if m.session == nil {
575 break
576 }
577 if msg.Payload.SessionID != m.session.ID {
578 // This might be a child session message from an agent tool.
579 if cmd := m.handleChildSessionMessage(msg); cmd != nil {
580 cmds = append(cmds, cmd)
581 }
582 break
583 }
584 switch msg.Type {
585 case pubsub.CreatedEvent:
586 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
587 case pubsub.UpdatedEvent:
588 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
589 case pubsub.DeletedEvent:
590 m.chat.RemoveMessage(msg.Payload.ID)
591 }
592 // start the spinner if there is a new message
593 if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
594 m.todoIsSpinning = true
595 cmds = append(cmds, m.todoSpinner.Tick)
596 }
597 // stop the spinner if the agent is not busy anymore
598 if m.todoIsSpinning && !m.isAgentBusy() {
599 m.todoIsSpinning = false
600 }
601 // there is a number of things that could change the pills here so we want to re-render
602 m.renderPills()
603 case pubsub.Event[history.File]:
604 cmds = append(cmds, m.handleFileEvent(msg.Payload))
605 case pubsub.Event[app.LSPEvent]:
606 m.lspStates = app.GetLSPStates()
607 case pubsub.Event[mcp.Event]:
608 switch msg.Payload.Type {
609 case mcp.EventStateChanged:
610 return m, tea.Batch(
611 m.handleStateChanged(),
612 m.loadMCPrompts,
613 )
614 case mcp.EventPromptsListChanged:
615 return m, handleMCPPromptsEvent(msg.Payload.Name)
616 case mcp.EventToolsListChanged:
617 return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
618 case mcp.EventResourcesListChanged:
619 return m, handleMCPResourcesEvent(msg.Payload.Name)
620 }
621 case pubsub.Event[permission.PermissionRequest]:
622 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
623 cmds = append(cmds, cmd)
624 }
625 if cmd := m.sendNotification(notification.Notification{
626 Title: "Crush is waiting...",
627 Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
628 }); cmd != nil {
629 cmds = append(cmds, cmd)
630 }
631 case pubsub.Event[permission.PermissionNotification]:
632 m.handlePermissionNotification(msg.Payload)
633 case cancelTimerExpiredMsg:
634 m.isCanceling = false
635 case tea.TerminalVersionMsg:
636 termVersion := strings.ToLower(msg.Name)
637 // Only enable progress bar for the following terminals.
638 if !m.sendProgressBar {
639 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
640 }
641 return m, nil
642 case tea.WindowSizeMsg:
643 m.width, m.height = msg.Width, msg.Height
644 m.updateLayoutAndSize()
645 if m.state == uiChat && m.chat.Follow() {
646 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
647 cmds = append(cmds, cmd)
648 }
649 }
650 case tea.KeyboardEnhancementsMsg:
651 m.keyenh = msg
652 if msg.SupportsKeyDisambiguation() {
653 m.keyMap.Models.SetHelp("ctrl+m", "models")
654 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
655 }
656 case copyChatHighlightMsg:
657 cmds = append(cmds, m.copyChatHighlight())
658 case DelayedClickMsg:
659 // Handle delayed single-click action (e.g., expansion).
660 m.chat.HandleDelayedClick(msg)
661 case tea.MouseClickMsg:
662 // Pass mouse events to dialogs first if any are open.
663 if m.dialog.HasDialogs() {
664 m.dialog.Update(msg)
665 return m, tea.Batch(cmds...)
666 }
667
668 if cmd := m.handleClickFocus(msg); cmd != nil {
669 cmds = append(cmds, cmd)
670 }
671
672 switch m.state {
673 case uiChat:
674 x, y := msg.X, msg.Y
675 // Adjust for chat area position
676 x -= m.layout.main.Min.X
677 y -= m.layout.main.Min.Y
678 if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
679 if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
680 m.lastClickTime = time.Now()
681 if cmd != nil {
682 cmds = append(cmds, cmd)
683 }
684 }
685 }
686 }
687
688 case tea.MouseMotionMsg:
689 // Pass mouse events to dialogs first if any are open.
690 if m.dialog.HasDialogs() {
691 m.dialog.Update(msg)
692 return m, tea.Batch(cmds...)
693 }
694
695 switch m.state {
696 case uiChat:
697 if msg.Y <= 0 {
698 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
699 cmds = append(cmds, cmd)
700 }
701 if !m.chat.SelectedItemInView() {
702 m.chat.SelectPrev()
703 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
704 cmds = append(cmds, cmd)
705 }
706 }
707 } else if msg.Y >= m.chat.Height()-1 {
708 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
709 cmds = append(cmds, cmd)
710 }
711 if !m.chat.SelectedItemInView() {
712 m.chat.SelectNext()
713 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
714 cmds = append(cmds, cmd)
715 }
716 }
717 }
718
719 x, y := msg.X, msg.Y
720 // Adjust for chat area position
721 x -= m.layout.main.Min.X
722 y -= m.layout.main.Min.Y
723 m.chat.HandleMouseDrag(x, y)
724 }
725
726 case tea.MouseReleaseMsg:
727 // Pass mouse events to dialogs first if any are open.
728 if m.dialog.HasDialogs() {
729 m.dialog.Update(msg)
730 return m, tea.Batch(cmds...)
731 }
732
733 switch m.state {
734 case uiChat:
735 x, y := msg.X, msg.Y
736 // Adjust for chat area position
737 x -= m.layout.main.Min.X
738 y -= m.layout.main.Min.Y
739 if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
740 cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
741 if time.Since(m.lastClickTime) >= doubleClickThreshold {
742 return copyChatHighlightMsg{}
743 }
744 return nil
745 }))
746 }
747 }
748 case tea.MouseWheelMsg:
749 // Pass mouse events to dialogs first if any are open.
750 if m.dialog.HasDialogs() {
751 m.dialog.Update(msg)
752 return m, tea.Batch(cmds...)
753 }
754
755 // Otherwise handle mouse wheel for chat.
756 switch m.state {
757 case uiChat:
758 switch msg.Button {
759 case tea.MouseWheelUp:
760 if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
761 cmds = append(cmds, cmd)
762 }
763 if !m.chat.SelectedItemInView() {
764 m.chat.SelectPrev()
765 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
766 cmds = append(cmds, cmd)
767 }
768 }
769 case tea.MouseWheelDown:
770 if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
771 cmds = append(cmds, cmd)
772 }
773 if !m.chat.SelectedItemInView() {
774 if m.chat.AtBottom() {
775 m.chat.SelectLast()
776 } else {
777 m.chat.SelectNext()
778 }
779 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
780 cmds = append(cmds, cmd)
781 }
782 }
783 }
784 }
785 case anim.StepMsg:
786 if m.state == uiChat {
787 if cmd := m.chat.Animate(msg); cmd != nil {
788 cmds = append(cmds, cmd)
789 }
790 if m.chat.Follow() {
791 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
792 cmds = append(cmds, cmd)
793 }
794 }
795 }
796 case spinner.TickMsg:
797 if m.dialog.HasDialogs() {
798 // route to dialog
799 if cmd := m.handleDialogMsg(msg); cmd != nil {
800 cmds = append(cmds, cmd)
801 }
802 }
803 if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
804 var cmd tea.Cmd
805 m.todoSpinner, cmd = m.todoSpinner.Update(msg)
806 if cmd != nil {
807 m.renderPills()
808 cmds = append(cmds, cmd)
809 }
810 }
811
812 case tea.KeyPressMsg:
813 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
814 cmds = append(cmds, cmd)
815 }
816 case tea.PasteMsg:
817 if cmd := m.handlePasteMsg(msg); cmd != nil {
818 cmds = append(cmds, cmd)
819 }
820 case openEditorMsg:
821 var cmd tea.Cmd
822 m.textarea.SetValue(msg.Text)
823 m.textarea.MoveToEnd()
824 m.textarea, cmd = m.textarea.Update(msg)
825 if cmd != nil {
826 cmds = append(cmds, cmd)
827 }
828 case util.InfoMsg:
829 m.status.SetInfoMsg(msg)
830 ttl := msg.TTL
831 if ttl <= 0 {
832 ttl = DefaultStatusTTL
833 }
834 cmds = append(cmds, clearInfoMsgCmd(ttl))
835 case util.ClearStatusMsg:
836 m.status.ClearInfoMsg()
837 case completions.CompletionItemsLoadedMsg:
838 if m.completionsOpen {
839 m.completions.SetItems(msg.Files, msg.Resources)
840 }
841 case uv.KittyGraphicsEvent:
842 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
843 slog.Warn("Unexpected Kitty graphics response",
844 "response", string(msg.Payload),
845 "options", msg.Options)
846 }
847 default:
848 if m.dialog.HasDialogs() {
849 if cmd := m.handleDialogMsg(msg); cmd != nil {
850 cmds = append(cmds, cmd)
851 }
852 }
853 }
854
855 // This logic gets triggered on any message type, but should it?
856 switch m.focus {
857 case uiFocusMain:
858 case uiFocusEditor:
859 // Textarea placeholder logic
860 if m.isAgentBusy() {
861 m.textarea.Placeholder = m.workingPlaceholder
862 } else {
863 m.textarea.Placeholder = m.readyPlaceholder
864 }
865 if m.com.App.Permissions.SkipRequests() {
866 m.textarea.Placeholder = "Yolo mode!"
867 }
868 }
869
870 // at this point this can only handle [message.Attachment] message, and we
871 // should return all cmds anyway.
872 _ = m.attachments.Update(msg)
873 return m, tea.Batch(cmds...)
874}
875
876// setSessionMessages sets the messages for the current session in the chat
877func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
878 var cmds []tea.Cmd
879 // Build tool result map to link tool calls with their results
880 msgPtrs := make([]*message.Message, len(msgs))
881 for i := range msgs {
882 msgPtrs[i] = &msgs[i]
883 }
884 toolResultMap := chat.BuildToolResultMap(msgPtrs)
885 if len(msgPtrs) > 0 {
886 m.lastUserMessageTime = msgPtrs[0].CreatedAt
887 }
888
889 // Add messages to chat with linked tool results
890 items := make([]chat.MessageItem, 0, len(msgs)*2)
891 for _, msg := range msgPtrs {
892 switch msg.Role {
893 case message.User:
894 m.lastUserMessageTime = msg.CreatedAt
895 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
896 case message.Assistant:
897 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
898 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
899 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
900 items = append(items, infoItem)
901 }
902 default:
903 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
904 }
905 }
906
907 // Load nested tool calls for agent/agentic_fetch tools.
908 m.loadNestedToolCalls(items)
909
910 // If the user switches between sessions while the agent is working we want
911 // to make sure the animations are shown.
912 for _, item := range items {
913 if animatable, ok := item.(chat.Animatable); ok {
914 if cmd := animatable.StartAnimation(); cmd != nil {
915 cmds = append(cmds, cmd)
916 }
917 }
918 }
919
920 m.chat.SetMessages(items...)
921 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
922 cmds = append(cmds, cmd)
923 }
924 m.chat.SelectLast()
925 return tea.Sequence(cmds...)
926}
927
928// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
929func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
930 for _, item := range items {
931 nestedContainer, ok := item.(chat.NestedToolContainer)
932 if !ok {
933 continue
934 }
935 toolItem, ok := item.(chat.ToolMessageItem)
936 if !ok {
937 continue
938 }
939
940 tc := toolItem.ToolCall()
941 messageID := toolItem.MessageID()
942
943 // Get the agent tool session ID.
944 agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
945
946 // Fetch nested messages.
947 nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
948 if err != nil || len(nestedMsgs) == 0 {
949 continue
950 }
951
952 // Build tool result map for nested messages.
953 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
954 for i := range nestedMsgs {
955 nestedMsgPtrs[i] = &nestedMsgs[i]
956 }
957 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
958
959 // Extract nested tool items.
960 var nestedTools []chat.ToolMessageItem
961 for _, nestedMsg := range nestedMsgPtrs {
962 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
963 for _, nestedItem := range nestedItems {
964 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
965 // Mark nested tools as simple (compact) rendering.
966 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
967 simplifiable.SetCompact(true)
968 }
969 nestedTools = append(nestedTools, nestedToolItem)
970 }
971 }
972 }
973
974 // Recursively load nested tool calls for any agent tools within.
975 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
976 for i, nt := range nestedTools {
977 nestedMessageItems[i] = nt
978 }
979 m.loadNestedToolCalls(nestedMessageItems)
980
981 // Set nested tools on the parent.
982 nestedContainer.SetNestedTools(nestedTools)
983 }
984}
985
986// appendSessionMessage appends a new message to the current session in the chat
987// if the message is a tool result it will update the corresponding tool call message
988func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
989 var cmds []tea.Cmd
990
991 existing := m.chat.MessageItem(msg.ID)
992 if existing != nil {
993 // message already exists, skip
994 return nil
995 }
996
997 switch msg.Role {
998 case message.User:
999 m.lastUserMessageTime = msg.CreatedAt
1000 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1001 for _, item := range items {
1002 if animatable, ok := item.(chat.Animatable); ok {
1003 if cmd := animatable.StartAnimation(); cmd != nil {
1004 cmds = append(cmds, cmd)
1005 }
1006 }
1007 }
1008 m.chat.AppendMessages(items...)
1009 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1010 cmds = append(cmds, cmd)
1011 }
1012 case message.Assistant:
1013 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1014 for _, item := range items {
1015 if animatable, ok := item.(chat.Animatable); ok {
1016 if cmd := animatable.StartAnimation(); cmd != nil {
1017 cmds = append(cmds, cmd)
1018 }
1019 }
1020 }
1021 m.chat.AppendMessages(items...)
1022 if m.chat.Follow() {
1023 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1024 cmds = append(cmds, cmd)
1025 }
1026 }
1027 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1028 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1029 m.chat.AppendMessages(infoItem)
1030 if m.chat.Follow() {
1031 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1032 cmds = append(cmds, cmd)
1033 }
1034 }
1035 }
1036 case message.Tool:
1037 for _, tr := range msg.ToolResults() {
1038 toolItem := m.chat.MessageItem(tr.ToolCallID)
1039 if toolItem == nil {
1040 // we should have an item!
1041 continue
1042 }
1043 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1044 toolMsgItem.SetResult(&tr)
1045 if m.chat.Follow() {
1046 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1047 cmds = append(cmds, cmd)
1048 }
1049 }
1050 }
1051 }
1052 }
1053 return tea.Sequence(cmds...)
1054}
1055
1056func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1057 switch {
1058 case m.state != uiChat:
1059 return nil
1060 case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1061 return nil
1062 case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1063 m.focus = uiFocusEditor
1064 cmd = m.textarea.Focus()
1065 m.chat.Blur()
1066 case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1067 m.focus = uiFocusMain
1068 m.textarea.Blur()
1069 m.chat.Focus()
1070 }
1071 return cmd
1072}
1073
1074// updateSessionMessage updates an existing message in the current session in the chat
1075// when an assistant message is updated it may include updated tool calls as well
1076// that is why we need to handle creating/updating each tool call message too
1077func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1078 var cmds []tea.Cmd
1079 existingItem := m.chat.MessageItem(msg.ID)
1080
1081 if existingItem != nil {
1082 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1083 assistantItem.SetMessage(&msg)
1084 }
1085 }
1086
1087 shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1088 // if the message of the assistant does not have any response just tool calls we need to remove it
1089 if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1090 m.chat.RemoveMessage(msg.ID)
1091 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1092 m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1093 }
1094 }
1095
1096 if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1097 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1098 newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1099 m.chat.AppendMessages(newInfoItem)
1100 }
1101 }
1102
1103 var items []chat.MessageItem
1104 for _, tc := range msg.ToolCalls() {
1105 existingToolItem := m.chat.MessageItem(tc.ID)
1106 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1107 existingToolCall := toolItem.ToolCall()
1108 // only update if finished state changed or input changed
1109 // to avoid clearing the cache
1110 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1111 toolItem.SetToolCall(tc)
1112 }
1113 }
1114 if existingToolItem == nil {
1115 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1116 }
1117 }
1118
1119 for _, item := range items {
1120 if animatable, ok := item.(chat.Animatable); ok {
1121 if cmd := animatable.StartAnimation(); cmd != nil {
1122 cmds = append(cmds, cmd)
1123 }
1124 }
1125 }
1126
1127 m.chat.AppendMessages(items...)
1128 if m.chat.Follow() {
1129 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1130 cmds = append(cmds, cmd)
1131 }
1132 m.chat.SelectLast()
1133 }
1134
1135 return tea.Sequence(cmds...)
1136}
1137
1138// handleChildSessionMessage handles messages from child sessions (agent tools).
1139func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1140 var cmds []tea.Cmd
1141
1142 // Only process messages with tool calls or results.
1143 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1144 return nil
1145 }
1146
1147 // Check if this is an agent tool session and parse it.
1148 childSessionID := event.Payload.SessionID
1149 _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1150 if !ok {
1151 return nil
1152 }
1153
1154 // Find the parent agent tool item.
1155 var agentItem chat.NestedToolContainer
1156 for i := 0; i < m.chat.Len(); i++ {
1157 item := m.chat.MessageItem(toolCallID)
1158 if item == nil {
1159 continue
1160 }
1161 if agent, ok := item.(chat.NestedToolContainer); ok {
1162 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1163 if toolMessageItem.ToolCall().ID == toolCallID {
1164 // Verify this agent belongs to the correct parent message.
1165 // We can't directly check parentMessageID on the item, so we trust the session parsing.
1166 agentItem = agent
1167 break
1168 }
1169 }
1170 }
1171 }
1172
1173 if agentItem == nil {
1174 return nil
1175 }
1176
1177 // Get existing nested tools.
1178 nestedTools := agentItem.NestedTools()
1179
1180 // Update or create nested tool calls.
1181 for _, tc := range event.Payload.ToolCalls() {
1182 found := false
1183 for _, existingTool := range nestedTools {
1184 if existingTool.ToolCall().ID == tc.ID {
1185 existingTool.SetToolCall(tc)
1186 found = true
1187 break
1188 }
1189 }
1190 if !found {
1191 // Create a new nested tool item.
1192 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1193 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1194 simplifiable.SetCompact(true)
1195 }
1196 if animatable, ok := nestedItem.(chat.Animatable); ok {
1197 if cmd := animatable.StartAnimation(); cmd != nil {
1198 cmds = append(cmds, cmd)
1199 }
1200 }
1201 nestedTools = append(nestedTools, nestedItem)
1202 }
1203 }
1204
1205 // Update nested tool results.
1206 for _, tr := range event.Payload.ToolResults() {
1207 for _, nestedTool := range nestedTools {
1208 if nestedTool.ToolCall().ID == tr.ToolCallID {
1209 nestedTool.SetResult(&tr)
1210 break
1211 }
1212 }
1213 }
1214
1215 // Update the agent item with the new nested tools.
1216 agentItem.SetNestedTools(nestedTools)
1217
1218 // Update the chat so it updates the index map for animations to work as expected
1219 m.chat.UpdateNestedToolIDs(toolCallID)
1220
1221 if m.chat.Follow() {
1222 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1223 cmds = append(cmds, cmd)
1224 }
1225 m.chat.SelectLast()
1226 }
1227
1228 return tea.Sequence(cmds...)
1229}
1230
1231func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1232 var cmds []tea.Cmd
1233 action := m.dialog.Update(msg)
1234 if action == nil {
1235 return tea.Batch(cmds...)
1236 }
1237
1238 isOnboarding := m.state == uiOnboarding
1239
1240 switch msg := action.(type) {
1241 // Generic dialog messages
1242 case dialog.ActionClose:
1243 if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1244 break
1245 }
1246
1247 if m.dialog.ContainsDialog(dialog.FilePickerID) {
1248 defer fimage.ResetCache()
1249 }
1250
1251 m.dialog.CloseFrontDialog()
1252
1253 if isOnboarding {
1254 if cmd := m.openModelsDialog(); cmd != nil {
1255 cmds = append(cmds, cmd)
1256 }
1257 }
1258
1259 if m.focus == uiFocusEditor {
1260 cmds = append(cmds, m.textarea.Focus())
1261 }
1262 case dialog.ActionCmd:
1263 if msg.Cmd != nil {
1264 cmds = append(cmds, msg.Cmd)
1265 }
1266
1267 // Session dialog messages.
1268 case dialog.ActionSelectSession:
1269 m.dialog.CloseDialog(dialog.SessionsID)
1270 cmds = append(cmds, m.loadSession(msg.Session.ID))
1271
1272 // Open dialog message.
1273 case dialog.ActionOpenDialog:
1274 m.dialog.CloseDialog(dialog.CommandsID)
1275 if cmd := m.openDialog(msg.DialogID); cmd != nil {
1276 cmds = append(cmds, cmd)
1277 }
1278
1279 // Command dialog messages.
1280 case dialog.ActionToggleYoloMode:
1281 yolo := !m.com.App.Permissions.SkipRequests()
1282 m.com.App.Permissions.SetSkipRequests(yolo)
1283 m.setEditorPrompt(yolo)
1284 m.dialog.CloseDialog(dialog.CommandsID)
1285 case dialog.ActionToggleNotifications:
1286 cfg := m.com.Config()
1287 if cfg != nil && cfg.Options != nil {
1288 disabled := !cfg.Options.DisableNotifications
1289 cfg.Options.DisableNotifications = disabled
1290 if err := m.com.Store().SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1291 cmds = append(cmds, util.ReportError(err))
1292 } else {
1293 status := "enabled"
1294 if disabled {
1295 status = "disabled"
1296 }
1297 cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
1298 }
1299 }
1300 m.dialog.CloseDialog(dialog.CommandsID)
1301 case dialog.ActionNewSession:
1302 if m.isAgentBusy() {
1303 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1304 break
1305 }
1306 if cmd := m.newSession(); cmd != nil {
1307 cmds = append(cmds, cmd)
1308 }
1309 m.dialog.CloseDialog(dialog.CommandsID)
1310 case dialog.ActionSummarize:
1311 if m.isAgentBusy() {
1312 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1313 break
1314 }
1315 cmds = append(cmds, func() tea.Msg {
1316 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1317 if err != nil {
1318 return util.ReportError(err)()
1319 }
1320 return nil
1321 })
1322 m.dialog.CloseDialog(dialog.CommandsID)
1323 case dialog.ActionToggleHelp:
1324 m.status.ToggleHelp()
1325 m.dialog.CloseDialog(dialog.CommandsID)
1326 case dialog.ActionExternalEditor:
1327 if m.isAgentBusy() {
1328 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1329 break
1330 }
1331 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1332 m.dialog.CloseDialog(dialog.CommandsID)
1333 case dialog.ActionToggleCompactMode:
1334 cmds = append(cmds, m.toggleCompactMode())
1335 m.dialog.CloseDialog(dialog.CommandsID)
1336 case dialog.ActionTogglePills:
1337 if cmd := m.togglePillsExpanded(); cmd != nil {
1338 cmds = append(cmds, cmd)
1339 }
1340 m.dialog.CloseDialog(dialog.CommandsID)
1341 case dialog.ActionToggleThinking:
1342 cmds = append(cmds, func() tea.Msg {
1343 cfg := m.com.Config()
1344 if cfg == nil {
1345 return util.ReportError(errors.New("configuration not found"))()
1346 }
1347
1348 agentCfg, ok := cfg.Agents[config.AgentCoder]
1349 if !ok {
1350 return util.ReportError(errors.New("agent configuration not found"))()
1351 }
1352
1353 currentModel := cfg.Models[agentCfg.Model]
1354 currentModel.Think = !currentModel.Think
1355 if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1356 return util.ReportError(err)()
1357 }
1358 m.com.App.UpdateAgentModel(context.TODO())
1359 status := "disabled"
1360 if currentModel.Think {
1361 status = "enabled"
1362 }
1363 return util.NewInfoMsg("Thinking mode " + status)
1364 })
1365 m.dialog.CloseDialog(dialog.CommandsID)
1366 case dialog.ActionToggleTransparentBackground:
1367 cmds = append(cmds, func() tea.Msg {
1368 cfg := m.com.Config()
1369 if cfg == nil {
1370 return util.ReportError(errors.New("configuration not found"))()
1371 }
1372
1373 isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
1374 newValue := !isTransparent
1375 if err := m.com.Store().SetTransparentBackground(config.ScopeGlobal, newValue); err != nil {
1376 return util.ReportError(err)()
1377 }
1378 m.isTransparent = newValue
1379
1380 status := "disabled"
1381 if newValue {
1382 status = "enabled"
1383 }
1384 return util.NewInfoMsg("Transparent background " + status)
1385 })
1386 m.dialog.CloseDialog(dialog.CommandsID)
1387 case dialog.ActionQuit:
1388 cmds = append(cmds, tea.Quit)
1389 case dialog.ActionEnableDockerMCP:
1390 m.dialog.CloseDialog(dialog.CommandsID)
1391 cmds = append(cmds, m.enableDockerMCP)
1392 case dialog.ActionDisableDockerMCP:
1393 m.dialog.CloseDialog(dialog.CommandsID)
1394 cmds = append(cmds, m.disableDockerMCP)
1395 case dialog.ActionInitializeProject:
1396 if m.isAgentBusy() {
1397 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1398 break
1399 }
1400 cmds = append(cmds, m.initializeProject())
1401 m.dialog.CloseDialog(dialog.CommandsID)
1402
1403 case dialog.ActionSelectModel:
1404 if m.isAgentBusy() {
1405 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1406 break
1407 }
1408
1409 cfg := m.com.Config()
1410 if cfg == nil {
1411 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1412 break
1413 }
1414
1415 var (
1416 providerID = msg.Model.Provider
1417 isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1418 isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1419 )
1420
1421 // Attempt to import GitHub Copilot tokens from VSCode if available.
1422 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1423 m.com.Store().ImportCopilot()
1424 }
1425
1426 if !isConfigured() || msg.ReAuthenticate {
1427 m.dialog.CloseDialog(dialog.ModelsID)
1428 if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1429 cmds = append(cmds, cmd)
1430 }
1431 break
1432 }
1433
1434 if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1435 cmds = append(cmds, util.ReportError(err))
1436 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1437 // Ensure small model is set is unset.
1438 smallModel := m.com.App.GetDefaultSmallModel(providerID)
1439 if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1440 cmds = append(cmds, util.ReportError(err))
1441 }
1442 }
1443
1444 cmds = append(cmds, func() tea.Msg {
1445 if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1446 return util.ReportError(err)
1447 }
1448
1449 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1450
1451 return util.NewInfoMsg(modelMsg)
1452 })
1453
1454 m.dialog.CloseDialog(dialog.APIKeyInputID)
1455 m.dialog.CloseDialog(dialog.OAuthID)
1456 m.dialog.CloseDialog(dialog.ModelsID)
1457
1458 if isOnboarding {
1459 m.setState(uiLanding, uiFocusEditor)
1460 m.com.Config().SetupAgents()
1461 if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1462 cmds = append(cmds, util.ReportError(err))
1463 }
1464 }
1465 case dialog.ActionSelectReasoningEffort:
1466 if m.isAgentBusy() {
1467 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1468 break
1469 }
1470
1471 cfg := m.com.Config()
1472 if cfg == nil {
1473 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1474 break
1475 }
1476
1477 agentCfg, ok := cfg.Agents[config.AgentCoder]
1478 if !ok {
1479 cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1480 break
1481 }
1482
1483 currentModel := cfg.Models[agentCfg.Model]
1484 currentModel.ReasoningEffort = msg.Effort
1485 if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1486 cmds = append(cmds, util.ReportError(err))
1487 break
1488 }
1489
1490 cmds = append(cmds, func() tea.Msg {
1491 m.com.App.UpdateAgentModel(context.TODO())
1492 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1493 })
1494 m.dialog.CloseDialog(dialog.ReasoningID)
1495 case dialog.ActionPermissionResponse:
1496 m.dialog.CloseDialog(dialog.PermissionsID)
1497 switch msg.Action {
1498 case dialog.PermissionAllow:
1499 m.com.App.Permissions.Grant(msg.Permission)
1500 case dialog.PermissionAllowForSession:
1501 m.com.App.Permissions.GrantPersistent(msg.Permission)
1502 case dialog.PermissionDeny:
1503 m.com.App.Permissions.Deny(msg.Permission)
1504 }
1505
1506 case dialog.ActionFilePickerSelected:
1507 cmds = append(cmds, tea.Sequence(
1508 msg.Cmd(),
1509 func() tea.Msg {
1510 m.dialog.CloseDialog(dialog.FilePickerID)
1511 return nil
1512 },
1513 func() tea.Msg {
1514 fimage.ResetCache()
1515 return nil
1516 },
1517 ))
1518
1519 case dialog.ActionRunCustomCommand:
1520 if len(msg.Arguments) > 0 && msg.Args == nil {
1521 m.dialog.CloseFrontDialog()
1522 argsDialog := dialog.NewArguments(
1523 m.com,
1524 "Custom Command Arguments",
1525 "",
1526 msg.Arguments,
1527 msg, // Pass the action as the result
1528 )
1529 m.dialog.OpenDialog(argsDialog)
1530 break
1531 }
1532 content := msg.Content
1533 if msg.Args != nil {
1534 content = substituteArgs(content, msg.Args)
1535 }
1536 cmds = append(cmds, m.sendMessage(content))
1537 m.dialog.CloseFrontDialog()
1538 case dialog.ActionRunMCPPrompt:
1539 if len(msg.Arguments) > 0 && msg.Args == nil {
1540 m.dialog.CloseFrontDialog()
1541 title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1542 argsDialog := dialog.NewArguments(
1543 m.com,
1544 title,
1545 msg.Description,
1546 msg.Arguments,
1547 msg, // Pass the action as the result
1548 )
1549 m.dialog.OpenDialog(argsDialog)
1550 break
1551 }
1552 cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1553 default:
1554 cmds = append(cmds, util.CmdHandler(msg))
1555 }
1556
1557 return tea.Batch(cmds...)
1558}
1559
1560// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1561func substituteArgs(content string, args map[string]string) string {
1562 for name, value := range args {
1563 placeholder := "$" + name
1564 content = strings.ReplaceAll(content, placeholder, value)
1565 }
1566 return content
1567}
1568
1569func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1570 var (
1571 dlg dialog.Dialog
1572 cmd tea.Cmd
1573
1574 isOnboarding = m.state == uiOnboarding
1575 )
1576
1577 switch provider.ID {
1578 case "hyper":
1579 dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1580 case catwalk.InferenceProviderCopilot:
1581 dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1582 default:
1583 dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1584 }
1585
1586 if m.dialog.ContainsDialog(dlg.ID()) {
1587 m.dialog.BringToFront(dlg.ID())
1588 return nil
1589 }
1590
1591 m.dialog.OpenDialog(dlg)
1592 return cmd
1593}
1594
1595func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1596 var cmds []tea.Cmd
1597
1598 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1599 switch {
1600 case key.Matches(msg, m.keyMap.Help):
1601 m.status.ToggleHelp()
1602 m.updateLayoutAndSize()
1603 return true
1604 case key.Matches(msg, m.keyMap.Commands):
1605 if cmd := m.openCommandsDialog(); cmd != nil {
1606 cmds = append(cmds, cmd)
1607 }
1608 return true
1609 case key.Matches(msg, m.keyMap.Models):
1610 if cmd := m.openModelsDialog(); cmd != nil {
1611 cmds = append(cmds, cmd)
1612 }
1613 return true
1614 case key.Matches(msg, m.keyMap.Sessions):
1615 if cmd := m.openSessionsDialog(); cmd != nil {
1616 cmds = append(cmds, cmd)
1617 }
1618 return true
1619 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1620 m.detailsOpen = !m.detailsOpen
1621 m.updateLayoutAndSize()
1622 return true
1623 case key.Matches(msg, m.keyMap.Chat.TogglePills):
1624 if m.state == uiChat && m.hasSession() {
1625 if cmd := m.togglePillsExpanded(); cmd != nil {
1626 cmds = append(cmds, cmd)
1627 }
1628 return true
1629 }
1630 case key.Matches(msg, m.keyMap.Chat.PillLeft):
1631 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1632 if cmd := m.switchPillSection(-1); cmd != nil {
1633 cmds = append(cmds, cmd)
1634 }
1635 return true
1636 }
1637 case key.Matches(msg, m.keyMap.Chat.PillRight):
1638 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1639 if cmd := m.switchPillSection(1); cmd != nil {
1640 cmds = append(cmds, cmd)
1641 }
1642 return true
1643 }
1644 case key.Matches(msg, m.keyMap.Suspend):
1645 if m.isAgentBusy() {
1646 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1647 return true
1648 }
1649 cmds = append(cmds, tea.Suspend)
1650 return true
1651 }
1652 return false
1653 }
1654
1655 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1656 // Always handle quit keys first
1657 if cmd := m.openQuitDialog(); cmd != nil {
1658 cmds = append(cmds, cmd)
1659 }
1660
1661 return tea.Batch(cmds...)
1662 }
1663
1664 // Route all messages to dialog if one is open.
1665 if m.dialog.HasDialogs() {
1666 return m.handleDialogMsg(msg)
1667 }
1668
1669 // Handle cancel key when agent is busy.
1670 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1671 if m.isAgentBusy() {
1672 if cmd := m.cancelAgent(); cmd != nil {
1673 cmds = append(cmds, cmd)
1674 }
1675 return tea.Batch(cmds...)
1676 }
1677 }
1678
1679 switch m.state {
1680 case uiOnboarding:
1681 return tea.Batch(cmds...)
1682 case uiInitialize:
1683 cmds = append(cmds, m.updateInitializeView(msg)...)
1684 return tea.Batch(cmds...)
1685 case uiChat, uiLanding:
1686 switch m.focus {
1687 case uiFocusEditor:
1688 // Handle completions if open.
1689 if m.completionsOpen {
1690 if msg, ok := m.completions.Update(msg); ok {
1691 switch msg := msg.(type) {
1692 case completions.SelectionMsg[completions.FileCompletionValue]:
1693 cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1694 if !msg.KeepOpen {
1695 m.closeCompletions()
1696 }
1697 case completions.SelectionMsg[completions.ResourceCompletionValue]:
1698 cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1699 if !msg.KeepOpen {
1700 m.closeCompletions()
1701 }
1702 case completions.ClosedMsg:
1703 m.completionsOpen = false
1704 }
1705 return tea.Batch(cmds...)
1706 }
1707 }
1708
1709 if ok := m.attachments.Update(msg); ok {
1710 return tea.Batch(cmds...)
1711 }
1712
1713 switch {
1714 case key.Matches(msg, m.keyMap.Editor.AddImage):
1715 if cmd := m.openFilesDialog(); cmd != nil {
1716 cmds = append(cmds, cmd)
1717 }
1718
1719 case key.Matches(msg, m.keyMap.Editor.PasteImage):
1720 cmds = append(cmds, m.pasteImageFromClipboard)
1721
1722 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1723 value := m.textarea.Value()
1724 if before, ok := strings.CutSuffix(value, "\\"); ok {
1725 // If the last character is a backslash, remove it and add a newline.
1726 m.textarea.SetValue(before)
1727 break
1728 }
1729
1730 // Otherwise, send the message
1731 m.textarea.Reset()
1732
1733 value = strings.TrimSpace(value)
1734 if value == "exit" || value == "quit" {
1735 return m.openQuitDialog()
1736 }
1737
1738 attachments := m.attachments.List()
1739 m.attachments.Reset()
1740 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1741 return nil
1742 }
1743
1744 m.randomizePlaceholders()
1745 m.historyReset()
1746
1747 return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1748 case key.Matches(msg, m.keyMap.Chat.NewSession):
1749 if !m.hasSession() {
1750 break
1751 }
1752 if m.isAgentBusy() {
1753 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1754 break
1755 }
1756 if cmd := m.newSession(); cmd != nil {
1757 cmds = append(cmds, cmd)
1758 }
1759 case key.Matches(msg, m.keyMap.Tab):
1760 if m.state != uiLanding {
1761 m.setState(m.state, uiFocusMain)
1762 m.textarea.Blur()
1763 m.chat.Focus()
1764 m.chat.SetSelected(m.chat.Len() - 1)
1765 }
1766 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1767 if m.isAgentBusy() {
1768 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1769 break
1770 }
1771 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1772 case key.Matches(msg, m.keyMap.Editor.Newline):
1773 m.textarea.InsertRune('\n')
1774 m.closeCompletions()
1775 ta, cmd := m.textarea.Update(msg)
1776 m.textarea = ta
1777 cmds = append(cmds, cmd)
1778 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1779 cmd := m.handleHistoryUp(msg)
1780 if cmd != nil {
1781 cmds = append(cmds, cmd)
1782 }
1783 case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1784 cmd := m.handleHistoryDown(msg)
1785 if cmd != nil {
1786 cmds = append(cmds, cmd)
1787 }
1788 case key.Matches(msg, m.keyMap.Editor.Escape):
1789 cmd := m.handleHistoryEscape(msg)
1790 if cmd != nil {
1791 cmds = append(cmds, cmd)
1792 }
1793 case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1794 if cmd := m.openCommandsDialog(); cmd != nil {
1795 cmds = append(cmds, cmd)
1796 }
1797 default:
1798 if handleGlobalKeys(msg) {
1799 // Handle global keys first before passing to textarea.
1800 break
1801 }
1802
1803 // Check for @ trigger before passing to textarea.
1804 curValue := m.textarea.Value()
1805 curIdx := len(curValue)
1806
1807 // Trigger completions on @.
1808 if msg.String() == "@" && !m.completionsOpen {
1809 // Only show if beginning of prompt or after whitespace.
1810 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1811 m.completionsOpen = true
1812 m.completionsQuery = ""
1813 m.completionsStartIndex = curIdx
1814 m.completionsPositionStart = m.completionsPosition()
1815 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1816 cmds = append(cmds, m.completions.Open(depth, limit))
1817 }
1818 }
1819
1820 // remove the details if they are open when user starts typing
1821 if m.detailsOpen {
1822 m.detailsOpen = false
1823 m.updateLayoutAndSize()
1824 }
1825
1826 ta, cmd := m.textarea.Update(msg)
1827 m.textarea = ta
1828 cmds = append(cmds, cmd)
1829
1830 // Any text modification becomes the current draft.
1831 m.updateHistoryDraft(curValue)
1832
1833 // After updating textarea, check if we need to filter completions.
1834 // Skip filtering on the initial @ keystroke since items are loading async.
1835 if m.completionsOpen && msg.String() != "@" {
1836 newValue := m.textarea.Value()
1837 newIdx := len(newValue)
1838
1839 // Close completions if cursor moved before start.
1840 if newIdx <= m.completionsStartIndex {
1841 m.closeCompletions()
1842 } else if msg.String() == "space" {
1843 // Close on space.
1844 m.closeCompletions()
1845 } else {
1846 // Extract current word and filter.
1847 word := m.textareaWord()
1848 if strings.HasPrefix(word, "@") {
1849 m.completionsQuery = word[1:]
1850 m.completions.Filter(m.completionsQuery)
1851 } else if m.completionsOpen {
1852 m.closeCompletions()
1853 }
1854 }
1855 }
1856 }
1857 case uiFocusMain:
1858 switch {
1859 case key.Matches(msg, m.keyMap.Tab):
1860 m.focus = uiFocusEditor
1861 cmds = append(cmds, m.textarea.Focus())
1862 m.chat.Blur()
1863 case key.Matches(msg, m.keyMap.Chat.NewSession):
1864 if !m.hasSession() {
1865 break
1866 }
1867 if m.isAgentBusy() {
1868 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1869 break
1870 }
1871 m.focus = uiFocusEditor
1872 if cmd := m.newSession(); cmd != nil {
1873 cmds = append(cmds, cmd)
1874 }
1875 case key.Matches(msg, m.keyMap.Chat.Expand):
1876 m.chat.ToggleExpandedSelectedItem()
1877 case key.Matches(msg, m.keyMap.Chat.Up):
1878 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1879 cmds = append(cmds, cmd)
1880 }
1881 if !m.chat.SelectedItemInView() {
1882 m.chat.SelectPrev()
1883 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1884 cmds = append(cmds, cmd)
1885 }
1886 }
1887 case key.Matches(msg, m.keyMap.Chat.Down):
1888 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1889 cmds = append(cmds, cmd)
1890 }
1891 if !m.chat.SelectedItemInView() {
1892 m.chat.SelectNext()
1893 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1894 cmds = append(cmds, cmd)
1895 }
1896 }
1897 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1898 m.chat.SelectPrev()
1899 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1900 cmds = append(cmds, cmd)
1901 }
1902 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1903 m.chat.SelectNext()
1904 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1905 cmds = append(cmds, cmd)
1906 }
1907 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1908 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1909 cmds = append(cmds, cmd)
1910 }
1911 m.chat.SelectFirstInView()
1912 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1913 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1914 cmds = append(cmds, cmd)
1915 }
1916 m.chat.SelectLastInView()
1917 case key.Matches(msg, m.keyMap.Chat.PageUp):
1918 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1919 cmds = append(cmds, cmd)
1920 }
1921 m.chat.SelectFirstInView()
1922 case key.Matches(msg, m.keyMap.Chat.PageDown):
1923 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1924 cmds = append(cmds, cmd)
1925 }
1926 m.chat.SelectLastInView()
1927 case key.Matches(msg, m.keyMap.Chat.Home):
1928 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1929 cmds = append(cmds, cmd)
1930 }
1931 m.chat.SelectFirst()
1932 case key.Matches(msg, m.keyMap.Chat.End):
1933 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1934 cmds = append(cmds, cmd)
1935 }
1936 m.chat.SelectLast()
1937 default:
1938 if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1939 cmds = append(cmds, cmd)
1940 } else {
1941 handleGlobalKeys(msg)
1942 }
1943 }
1944 default:
1945 handleGlobalKeys(msg)
1946 }
1947 default:
1948 handleGlobalKeys(msg)
1949 }
1950
1951 return tea.Sequence(cmds...)
1952}
1953
1954// drawHeader draws the header section of the UI.
1955func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1956 m.header.drawHeader(
1957 scr,
1958 area,
1959 m.session,
1960 m.isCompact,
1961 m.detailsOpen,
1962 area.Dx(),
1963 )
1964}
1965
1966// Draw implements [uv.Drawable] and draws the UI model.
1967func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1968 layout := m.generateLayout(area.Dx(), area.Dy())
1969
1970 if m.layout != layout {
1971 m.layout = layout
1972 m.updateSize()
1973 }
1974
1975 // Clear the screen first
1976 screen.Clear(scr)
1977
1978 switch m.state {
1979 case uiOnboarding:
1980 m.drawHeader(scr, layout.header)
1981
1982 // NOTE: Onboarding flow will be rendered as dialogs below, but
1983 // positioned at the bottom left of the screen.
1984
1985 case uiInitialize:
1986 m.drawHeader(scr, layout.header)
1987
1988 main := uv.NewStyledString(m.initializeView())
1989 main.Draw(scr, layout.main)
1990
1991 case uiLanding:
1992 m.drawHeader(scr, layout.header)
1993 main := uv.NewStyledString(m.landingView())
1994 main.Draw(scr, layout.main)
1995
1996 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1997 editor.Draw(scr, layout.editor)
1998
1999 case uiChat:
2000 if m.isCompact {
2001 m.drawHeader(scr, layout.header)
2002 } else {
2003 m.drawSidebar(scr, layout.sidebar)
2004 }
2005
2006 m.chat.Draw(scr, layout.main)
2007 if layout.pills.Dy() > 0 && m.pillsView != "" {
2008 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2009 }
2010
2011 editorWidth := scr.Bounds().Dx()
2012 if !m.isCompact {
2013 editorWidth -= layout.sidebar.Dx()
2014 }
2015 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2016 editor.Draw(scr, layout.editor)
2017
2018 // Draw details overlay in compact mode when open
2019 if m.isCompact && m.detailsOpen {
2020 m.drawSessionDetails(scr, layout.sessionDetails)
2021 }
2022 }
2023
2024 isOnboarding := m.state == uiOnboarding
2025
2026 // Add status and help layer
2027 m.status.SetHideHelp(isOnboarding)
2028 m.status.Draw(scr, layout.status)
2029
2030 // Draw completions popup if open
2031 if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2032 w, h := m.completions.Size()
2033 x := m.completionsPositionStart.X
2034 y := m.completionsPositionStart.Y - h
2035
2036 screenW := area.Dx()
2037 if x+w > screenW {
2038 x = screenW - w
2039 }
2040 x = max(0, x)
2041 y = max(0, y+1) // Offset for attachments row
2042
2043 completionsView := uv.NewStyledString(m.completions.Render())
2044 completionsView.Draw(scr, image.Rectangle{
2045 Min: image.Pt(x, y),
2046 Max: image.Pt(x+w, y+h),
2047 })
2048 }
2049
2050 // Debugging rendering (visually see when the tui rerenders)
2051 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2052 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2053 debug := uv.NewStyledString(debugView.String())
2054 debug.Draw(scr, image.Rectangle{
2055 Min: image.Pt(4, 1),
2056 Max: image.Pt(8, 3),
2057 })
2058 }
2059
2060 // This needs to come last to overlay on top of everything. We always pass
2061 // the full screen bounds because the dialogs will position themselves
2062 // accordingly.
2063 if m.dialog.HasDialogs() {
2064 return m.dialog.Draw(scr, scr.Bounds())
2065 }
2066
2067 switch m.focus {
2068 case uiFocusEditor:
2069 if m.layout.editor.Dy() <= 0 {
2070 // Don't show cursor if editor is not visible
2071 return nil
2072 }
2073 if m.detailsOpen && m.isCompact {
2074 // Don't show cursor if details overlay is open
2075 return nil
2076 }
2077
2078 if m.textarea.Focused() {
2079 cur := m.textarea.Cursor()
2080 cur.X++ // Adjust for app margins
2081 cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2082 return cur
2083 }
2084 }
2085 return nil
2086}
2087
2088// View renders the UI model's view.
2089func (m *UI) View() tea.View {
2090 var v tea.View
2091 v.AltScreen = true
2092 if !m.isTransparent {
2093 v.BackgroundColor = m.com.Styles.Background
2094 }
2095 v.MouseMode = tea.MouseModeCellMotion
2096 v.ReportFocus = m.caps.ReportFocusEvents
2097 v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
2098
2099 canvas := uv.NewScreenBuffer(m.width, m.height)
2100 v.Cursor = m.Draw(canvas, canvas.Bounds())
2101
2102 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2103 contentLines := strings.Split(content, "\n")
2104 for i, line := range contentLines {
2105 // Trim trailing spaces for concise rendering
2106 contentLines[i] = strings.TrimRight(line, " ")
2107 }
2108
2109 content = strings.Join(contentLines, "\n")
2110
2111 v.Content = content
2112 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2113 // HACK: use a random percentage to prevent ghostty from hiding it
2114 // after a timeout.
2115 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2116 }
2117
2118 return v
2119}
2120
2121// ShortHelp implements [help.KeyMap].
2122func (m *UI) ShortHelp() []key.Binding {
2123 var binds []key.Binding
2124 k := &m.keyMap
2125 tab := k.Tab
2126 commands := k.Commands
2127 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2128 commands.SetHelp("/ or ctrl+p", "commands")
2129 }
2130
2131 switch m.state {
2132 case uiInitialize:
2133 binds = append(binds, k.Quit)
2134 case uiChat:
2135 // Show cancel binding if agent is busy.
2136 if m.isAgentBusy() {
2137 cancelBinding := k.Chat.Cancel
2138 if m.isCanceling {
2139 cancelBinding.SetHelp("esc", "press again to cancel")
2140 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2141 cancelBinding.SetHelp("esc", "clear queue")
2142 }
2143 binds = append(binds, cancelBinding)
2144 }
2145
2146 if m.focus == uiFocusEditor {
2147 tab.SetHelp("tab", "focus chat")
2148 } else {
2149 tab.SetHelp("tab", "focus editor")
2150 }
2151
2152 binds = append(binds,
2153 tab,
2154 commands,
2155 k.Models,
2156 )
2157
2158 switch m.focus {
2159 case uiFocusEditor:
2160 binds = append(binds,
2161 k.Editor.Newline,
2162 )
2163 case uiFocusMain:
2164 binds = append(binds,
2165 k.Chat.UpDown,
2166 k.Chat.UpDownOneItem,
2167 k.Chat.PageUp,
2168 k.Chat.PageDown,
2169 k.Chat.Copy,
2170 )
2171 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2172 binds = append(binds, k.Chat.PillLeft)
2173 }
2174 }
2175 default:
2176 // TODO: other states
2177 // if m.session == nil {
2178 // no session selected
2179 binds = append(binds,
2180 commands,
2181 k.Models,
2182 k.Editor.Newline,
2183 )
2184 }
2185
2186 binds = append(binds,
2187 k.Quit,
2188 k.Help,
2189 )
2190
2191 return binds
2192}
2193
2194// FullHelp implements [help.KeyMap].
2195func (m *UI) FullHelp() [][]key.Binding {
2196 var binds [][]key.Binding
2197 k := &m.keyMap
2198 help := k.Help
2199 help.SetHelp("ctrl+g", "less")
2200 hasAttachments := len(m.attachments.List()) > 0
2201 hasSession := m.hasSession()
2202 commands := k.Commands
2203 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2204 commands.SetHelp("/ or ctrl+p", "commands")
2205 }
2206
2207 switch m.state {
2208 case uiInitialize:
2209 binds = append(binds,
2210 []key.Binding{
2211 k.Quit,
2212 })
2213 case uiChat:
2214 // Show cancel binding if agent is busy.
2215 if m.isAgentBusy() {
2216 cancelBinding := k.Chat.Cancel
2217 if m.isCanceling {
2218 cancelBinding.SetHelp("esc", "press again to cancel")
2219 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2220 cancelBinding.SetHelp("esc", "clear queue")
2221 }
2222 binds = append(binds, []key.Binding{cancelBinding})
2223 }
2224
2225 mainBinds := []key.Binding{}
2226 tab := k.Tab
2227 if m.focus == uiFocusEditor {
2228 tab.SetHelp("tab", "focus chat")
2229 } else {
2230 tab.SetHelp("tab", "focus editor")
2231 }
2232
2233 mainBinds = append(mainBinds,
2234 tab,
2235 commands,
2236 k.Models,
2237 k.Sessions,
2238 )
2239 if hasSession {
2240 mainBinds = append(mainBinds, k.Chat.NewSession)
2241 }
2242
2243 binds = append(binds, mainBinds)
2244
2245 switch m.focus {
2246 case uiFocusEditor:
2247 binds = append(binds,
2248 []key.Binding{
2249 k.Editor.Newline,
2250 k.Editor.AddImage,
2251 k.Editor.PasteImage,
2252 k.Editor.MentionFile,
2253 k.Editor.OpenEditor,
2254 },
2255 )
2256 if hasAttachments {
2257 binds = append(binds,
2258 []key.Binding{
2259 k.Editor.AttachmentDeleteMode,
2260 k.Editor.DeleteAllAttachments,
2261 k.Editor.Escape,
2262 },
2263 )
2264 }
2265 case uiFocusMain:
2266 binds = append(binds,
2267 []key.Binding{
2268 k.Chat.UpDown,
2269 k.Chat.UpDownOneItem,
2270 k.Chat.PageUp,
2271 k.Chat.PageDown,
2272 },
2273 []key.Binding{
2274 k.Chat.HalfPageUp,
2275 k.Chat.HalfPageDown,
2276 k.Chat.Home,
2277 k.Chat.End,
2278 },
2279 []key.Binding{
2280 k.Chat.Copy,
2281 k.Chat.ClearHighlight,
2282 },
2283 )
2284 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2285 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2286 }
2287 }
2288 default:
2289 if m.session == nil {
2290 // no session selected
2291 binds = append(binds,
2292 []key.Binding{
2293 commands,
2294 k.Models,
2295 k.Sessions,
2296 },
2297 []key.Binding{
2298 k.Editor.Newline,
2299 k.Editor.AddImage,
2300 k.Editor.PasteImage,
2301 k.Editor.MentionFile,
2302 k.Editor.OpenEditor,
2303 },
2304 )
2305 if hasAttachments {
2306 binds = append(binds,
2307 []key.Binding{
2308 k.Editor.AttachmentDeleteMode,
2309 k.Editor.DeleteAllAttachments,
2310 k.Editor.Escape,
2311 },
2312 )
2313 }
2314 binds = append(binds,
2315 []key.Binding{
2316 help,
2317 },
2318 )
2319 }
2320 }
2321
2322 binds = append(binds,
2323 []key.Binding{
2324 help,
2325 k.Quit,
2326 },
2327 )
2328
2329 return binds
2330}
2331
2332// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2333func (m *UI) toggleCompactMode() tea.Cmd {
2334 m.forceCompactMode = !m.forceCompactMode
2335
2336 err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2337 if err != nil {
2338 return util.ReportError(err)
2339 }
2340
2341 m.updateLayoutAndSize()
2342
2343 return nil
2344}
2345
2346// updateLayoutAndSize updates the layout and sizes of UI components.
2347func (m *UI) updateLayoutAndSize() {
2348 // Determine if we should be in compact mode
2349 if m.state == uiChat {
2350 if m.forceCompactMode {
2351 m.isCompact = true
2352 return
2353 }
2354 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2355 m.isCompact = true
2356 } else {
2357 m.isCompact = false
2358 }
2359 }
2360
2361 m.layout = m.generateLayout(m.width, m.height)
2362 m.updateSize()
2363}
2364
2365// updateSize updates the sizes of UI components based on the current layout.
2366func (m *UI) updateSize() {
2367 // Set status width
2368 m.status.SetWidth(m.layout.status.Dx())
2369
2370 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2371 m.textarea.SetWidth(m.layout.editor.Dx())
2372 // TODO: Abstract the textarea and attachments into a single editor
2373 // component so we don't have to manually account for the attachments
2374 // height here.
2375 m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2376 m.renderPills()
2377
2378 // Handle different app states
2379 switch m.state {
2380 case uiChat:
2381 if !m.isCompact {
2382 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2383 }
2384 }
2385}
2386
2387// generateLayout calculates the layout rectangles for all UI components based
2388// on the current UI state and terminal dimensions.
2389func (m *UI) generateLayout(w, h int) uiLayout {
2390 // The screen area we're working with
2391 area := image.Rect(0, 0, w, h)
2392
2393 // The help height
2394 helpHeight := 1
2395 // The editor height
2396 editorHeight := 5
2397 // The sidebar width
2398 sidebarWidth := 30
2399 // The header height
2400 const landingHeaderHeight = 4
2401
2402 var helpKeyMap help.KeyMap = m
2403 if m.status != nil && m.status.ShowingAll() {
2404 for _, row := range helpKeyMap.FullHelp() {
2405 helpHeight = max(helpHeight, len(row))
2406 }
2407 }
2408
2409 // Add app margins
2410 appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2411 appRect.Min.Y += 1
2412 appRect.Max.Y -= 1
2413 helpRect.Min.Y -= 1
2414 appRect.Min.X += 1
2415 appRect.Max.X -= 1
2416
2417 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2418 // extra padding on left and right for these states
2419 appRect.Min.X += 1
2420 appRect.Max.X -= 1
2421 }
2422
2423 uiLayout := uiLayout{
2424 area: area,
2425 status: helpRect,
2426 }
2427
2428 // Handle different app states
2429 switch m.state {
2430 case uiOnboarding, uiInitialize:
2431 // Layout
2432 //
2433 // header
2434 // ------
2435 // main
2436 // ------
2437 // help
2438
2439 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2440 uiLayout.header = headerRect
2441 uiLayout.main = mainRect
2442
2443 case uiLanding:
2444 // Layout
2445 //
2446 // header
2447 // ------
2448 // main
2449 // ------
2450 // editor
2451 // ------
2452 // help
2453 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2454 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2455 // Remove extra padding from editor (but keep it for header and main)
2456 editorRect.Min.X -= 1
2457 editorRect.Max.X += 1
2458 uiLayout.header = headerRect
2459 uiLayout.main = mainRect
2460 uiLayout.editor = editorRect
2461
2462 case uiChat:
2463 if m.isCompact {
2464 // Layout
2465 //
2466 // compact-header
2467 // ------
2468 // main
2469 // ------
2470 // editor
2471 // ------
2472 // help
2473 const compactHeaderHeight = 1
2474 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2475 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2476 sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2477 uiLayout.sessionDetails = sessionDetailsArea
2478 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2479 // Add one line gap between header and main content
2480 mainRect.Min.Y += 1
2481 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2482 mainRect.Max.X -= 1 // Add padding right
2483 uiLayout.header = headerRect
2484 pillsHeight := m.pillsAreaHeight()
2485 if pillsHeight > 0 {
2486 pillsHeight = min(pillsHeight, mainRect.Dy())
2487 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2488 uiLayout.main = chatRect
2489 uiLayout.pills = pillsRect
2490 } else {
2491 uiLayout.main = mainRect
2492 }
2493 // Add bottom margin to main
2494 uiLayout.main.Max.Y -= 1
2495 uiLayout.editor = editorRect
2496 } else {
2497 // Layout
2498 //
2499 // ------|---
2500 // main |
2501 // ------| side
2502 // editor|
2503 // ----------
2504 // help
2505
2506 mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2507 // Add padding left
2508 sideRect.Min.X += 1
2509 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2510 mainRect.Max.X -= 1 // Add padding right
2511 uiLayout.sidebar = sideRect
2512 pillsHeight := m.pillsAreaHeight()
2513 if pillsHeight > 0 {
2514 pillsHeight = min(pillsHeight, mainRect.Dy())
2515 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2516 uiLayout.main = chatRect
2517 uiLayout.pills = pillsRect
2518 } else {
2519 uiLayout.main = mainRect
2520 }
2521 // Add bottom margin to main
2522 uiLayout.main.Max.Y -= 1
2523 uiLayout.editor = editorRect
2524 }
2525 }
2526
2527 return uiLayout
2528}
2529
2530// uiLayout defines the positioning of UI elements.
2531type uiLayout struct {
2532 // area is the overall available area.
2533 area uv.Rectangle
2534
2535 // header is the header shown in special cases
2536 // e.x when the sidebar is collapsed
2537 // or when in the landing page
2538 // or in init/config
2539 header uv.Rectangle
2540
2541 // main is the area for the main pane. (e.x chat, configure, landing)
2542 main uv.Rectangle
2543
2544 // pills is the area for the pills panel.
2545 pills uv.Rectangle
2546
2547 // editor is the area for the editor pane.
2548 editor uv.Rectangle
2549
2550 // sidebar is the area for the sidebar.
2551 sidebar uv.Rectangle
2552
2553 // status is the area for the status view.
2554 status uv.Rectangle
2555
2556 // session details is the area for the session details overlay in compact mode.
2557 sessionDetails uv.Rectangle
2558}
2559
2560func (m *UI) openEditor(value string) tea.Cmd {
2561 tmpfile, err := os.CreateTemp("", "msg_*.md")
2562 if err != nil {
2563 return util.ReportError(err)
2564 }
2565 defer tmpfile.Close() //nolint:errcheck
2566 if _, err := tmpfile.WriteString(value); err != nil {
2567 return util.ReportError(err)
2568 }
2569 cmd, err := editor.Command(
2570 "crush",
2571 tmpfile.Name(),
2572 editor.AtPosition(
2573 m.textarea.Line()+1,
2574 m.textarea.Column()+1,
2575 ),
2576 )
2577 if err != nil {
2578 return util.ReportError(err)
2579 }
2580 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2581 if err != nil {
2582 return util.ReportError(err)
2583 }
2584 content, err := os.ReadFile(tmpfile.Name())
2585 if err != nil {
2586 return util.ReportError(err)
2587 }
2588 if len(content) == 0 {
2589 return util.ReportWarn("Message is empty")
2590 }
2591 os.Remove(tmpfile.Name())
2592 return openEditorMsg{
2593 Text: strings.TrimSpace(string(content)),
2594 }
2595 })
2596}
2597
2598// setEditorPrompt configures the textarea prompt function based on whether
2599// yolo mode is enabled.
2600func (m *UI) setEditorPrompt(yolo bool) {
2601 if yolo {
2602 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2603 return
2604 }
2605 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2606}
2607
2608// normalPromptFunc returns the normal editor prompt style (" > " on first
2609// line, "::: " on subsequent lines).
2610func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2611 t := m.com.Styles
2612 if info.LineNumber == 0 {
2613 if info.Focused {
2614 return " > "
2615 }
2616 return "::: "
2617 }
2618 if info.Focused {
2619 return t.EditorPromptNormalFocused.Render()
2620 }
2621 return t.EditorPromptNormalBlurred.Render()
2622}
2623
2624// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2625// and colored dots.
2626func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2627 t := m.com.Styles
2628 if info.LineNumber == 0 {
2629 if info.Focused {
2630 return t.EditorPromptYoloIconFocused.Render()
2631 } else {
2632 return t.EditorPromptYoloIconBlurred.Render()
2633 }
2634 }
2635 if info.Focused {
2636 return t.EditorPromptYoloDotsFocused.Render()
2637 }
2638 return t.EditorPromptYoloDotsBlurred.Render()
2639}
2640
2641// closeCompletions closes the completions popup and resets state.
2642func (m *UI) closeCompletions() {
2643 m.completionsOpen = false
2644 m.completionsQuery = ""
2645 m.completionsStartIndex = 0
2646 m.completions.Close()
2647}
2648
2649// insertCompletionText replaces the @query in the textarea with the given text.
2650// Returns false if the replacement cannot be performed.
2651func (m *UI) insertCompletionText(text string) bool {
2652 value := m.textarea.Value()
2653 if m.completionsStartIndex > len(value) {
2654 return false
2655 }
2656
2657 word := m.textareaWord()
2658 endIdx := min(m.completionsStartIndex+len(word), len(value))
2659 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2660 m.textarea.SetValue(newValue)
2661 m.textarea.MoveToEnd()
2662 m.textarea.InsertRune(' ')
2663 return true
2664}
2665
2666// insertFileCompletion inserts the selected file path into the textarea,
2667// replacing the @query, and adds the file as an attachment.
2668func (m *UI) insertFileCompletion(path string) tea.Cmd {
2669 if !m.insertCompletionText(path) {
2670 return nil
2671 }
2672
2673 return func() tea.Msg {
2674 absPath, _ := filepath.Abs(path)
2675
2676 if m.hasSession() {
2677 // Skip attachment if file was already read and hasn't been modified.
2678 lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2679 if !lastRead.IsZero() {
2680 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2681 return nil
2682 }
2683 }
2684 } else if slices.Contains(m.sessionFileReads, absPath) {
2685 return nil
2686 }
2687
2688 m.sessionFileReads = append(m.sessionFileReads, absPath)
2689
2690 // Add file as attachment.
2691 content, err := os.ReadFile(path)
2692 if err != nil {
2693 // If it fails, let the LLM handle it later.
2694 return nil
2695 }
2696
2697 return message.Attachment{
2698 FilePath: path,
2699 FileName: filepath.Base(path),
2700 MimeType: mimeOf(content),
2701 Content: content,
2702 }
2703 }
2704}
2705
2706// insertMCPResourceCompletion inserts the selected resource into the textarea,
2707// replacing the @query, and adds the resource as an attachment.
2708func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2709 displayText := cmp.Or(item.Title, item.URI)
2710
2711 if !m.insertCompletionText(displayText) {
2712 return nil
2713 }
2714
2715 return func() tea.Msg {
2716 contents, err := mcp.ReadResource(
2717 context.Background(),
2718 m.com.Store(),
2719 item.MCPName,
2720 item.URI,
2721 )
2722 if err != nil {
2723 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2724 return nil
2725 }
2726 if len(contents) == 0 {
2727 return nil
2728 }
2729
2730 content := contents[0]
2731 var data []byte
2732 if content.Text != "" {
2733 data = []byte(content.Text)
2734 } else if len(content.Blob) > 0 {
2735 data = content.Blob
2736 }
2737 if len(data) == 0 {
2738 return nil
2739 }
2740
2741 mimeType := item.MIMEType
2742 if mimeType == "" && content.MIMEType != "" {
2743 mimeType = content.MIMEType
2744 }
2745 if mimeType == "" {
2746 mimeType = "text/plain"
2747 }
2748
2749 return message.Attachment{
2750 FilePath: item.URI,
2751 FileName: displayText,
2752 MimeType: mimeType,
2753 Content: data,
2754 }
2755 }
2756}
2757
2758// completionsPosition returns the X and Y position for the completions popup.
2759func (m *UI) completionsPosition() image.Point {
2760 cur := m.textarea.Cursor()
2761 if cur == nil {
2762 return image.Point{
2763 X: m.layout.editor.Min.X,
2764 Y: m.layout.editor.Min.Y,
2765 }
2766 }
2767 return image.Point{
2768 X: cur.X + m.layout.editor.Min.X,
2769 Y: m.layout.editor.Min.Y + cur.Y,
2770 }
2771}
2772
2773// textareaWord returns the current word at the cursor position.
2774func (m *UI) textareaWord() string {
2775 return m.textarea.Word()
2776}
2777
2778// isWhitespace returns true if the byte is a whitespace character.
2779func isWhitespace(b byte) bool {
2780 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2781}
2782
2783// isAgentBusy returns true if the agent coordinator exists and is currently
2784// busy processing a request.
2785func (m *UI) isAgentBusy() bool {
2786 return m.com.App != nil &&
2787 m.com.App.AgentCoordinator != nil &&
2788 m.com.App.AgentCoordinator.IsBusy()
2789}
2790
2791// hasSession returns true if there is an active session with a valid ID.
2792func (m *UI) hasSession() bool {
2793 return m.session != nil && m.session.ID != ""
2794}
2795
2796// mimeOf detects the MIME type of the given content.
2797func mimeOf(content []byte) string {
2798 mimeBufferSize := min(512, len(content))
2799 return http.DetectContentType(content[:mimeBufferSize])
2800}
2801
2802var readyPlaceholders = [...]string{
2803 "Ready!",
2804 "Ready...",
2805 "Ready?",
2806 "Ready for instructions",
2807}
2808
2809var workingPlaceholders = [...]string{
2810 "Working!",
2811 "Working...",
2812 "Brrrrr...",
2813 "Prrrrrrrr...",
2814 "Processing...",
2815 "Thinking...",
2816}
2817
2818// randomizePlaceholders selects random placeholder text for the textarea's
2819// ready and working states.
2820func (m *UI) randomizePlaceholders() {
2821 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2822 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2823}
2824
2825// renderEditorView renders the editor view with attachments if any.
2826func (m *UI) renderEditorView(width int) string {
2827 var attachmentsView string
2828 if len(m.attachments.List()) > 0 {
2829 attachmentsView = m.attachments.Render(width)
2830 }
2831 return strings.Join([]string{
2832 attachmentsView,
2833 m.textarea.View(),
2834 "", // margin at bottom of editor
2835 }, "\n")
2836}
2837
2838// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2839func (m *UI) cacheSidebarLogo(width int) {
2840 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2841}
2842
2843// sendMessage sends a message with the given content and attachments.
2844func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2845 if m.com.App.AgentCoordinator == nil {
2846 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2847 }
2848
2849 var cmds []tea.Cmd
2850 if !m.hasSession() {
2851 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2852 if err != nil {
2853 return util.ReportError(err)
2854 }
2855 if m.forceCompactMode {
2856 m.isCompact = true
2857 }
2858 if newSession.ID != "" {
2859 m.session = &newSession
2860 cmds = append(cmds, m.loadSession(newSession.ID))
2861 }
2862 m.setState(uiChat, m.focus)
2863 }
2864
2865 ctx := context.Background()
2866 cmds = append(cmds, func() tea.Msg {
2867 for _, path := range m.sessionFileReads {
2868 m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2869 m.com.App.LSPManager.Start(ctx, path)
2870 }
2871 return nil
2872 })
2873
2874 // Capture session ID to avoid race with main goroutine updating m.session.
2875 sessionID := m.session.ID
2876 cmds = append(cmds, func() tea.Msg {
2877 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2878 if err != nil {
2879 isCancelErr := errors.Is(err, context.Canceled)
2880 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2881 if isCancelErr || isPermissionErr {
2882 return nil
2883 }
2884 return util.InfoMsg{
2885 Type: util.InfoTypeError,
2886 Msg: err.Error(),
2887 }
2888 }
2889 return nil
2890 })
2891 return tea.Batch(cmds...)
2892}
2893
2894const cancelTimerDuration = 2 * time.Second
2895
2896// cancelTimerCmd creates a command that expires the cancel timer.
2897func cancelTimerCmd() tea.Cmd {
2898 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2899 return cancelTimerExpiredMsg{}
2900 })
2901}
2902
2903// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2904// and starts a timer. The second press (before the timer expires) actually
2905// cancels the agent.
2906func (m *UI) cancelAgent() tea.Cmd {
2907 if !m.hasSession() {
2908 return nil
2909 }
2910
2911 coordinator := m.com.App.AgentCoordinator
2912 if coordinator == nil {
2913 return nil
2914 }
2915
2916 if m.isCanceling {
2917 // Second escape press - actually cancel the agent.
2918 m.isCanceling = false
2919 coordinator.Cancel(m.session.ID)
2920 // Stop the spinning todo indicator.
2921 m.todoIsSpinning = false
2922 m.renderPills()
2923 return nil
2924 }
2925
2926 // Check if there are queued prompts - if so, clear the queue.
2927 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2928 coordinator.ClearQueue(m.session.ID)
2929 return nil
2930 }
2931
2932 // First escape press - set canceling state and start timer.
2933 m.isCanceling = true
2934 return cancelTimerCmd()
2935}
2936
2937// openDialog opens a dialog by its ID.
2938func (m *UI) openDialog(id string) tea.Cmd {
2939 var cmds []tea.Cmd
2940 switch id {
2941 case dialog.SessionsID:
2942 if cmd := m.openSessionsDialog(); cmd != nil {
2943 cmds = append(cmds, cmd)
2944 }
2945 case dialog.ModelsID:
2946 if cmd := m.openModelsDialog(); cmd != nil {
2947 cmds = append(cmds, cmd)
2948 }
2949 case dialog.CommandsID:
2950 if cmd := m.openCommandsDialog(); cmd != nil {
2951 cmds = append(cmds, cmd)
2952 }
2953 case dialog.ReasoningID:
2954 if cmd := m.openReasoningDialog(); cmd != nil {
2955 cmds = append(cmds, cmd)
2956 }
2957 case dialog.QuitID:
2958 if cmd := m.openQuitDialog(); cmd != nil {
2959 cmds = append(cmds, cmd)
2960 }
2961 default:
2962 // Unknown dialog
2963 break
2964 }
2965 return tea.Batch(cmds...)
2966}
2967
2968// openQuitDialog opens the quit confirmation dialog.
2969func (m *UI) openQuitDialog() tea.Cmd {
2970 if m.dialog.ContainsDialog(dialog.QuitID) {
2971 // Bring to front
2972 m.dialog.BringToFront(dialog.QuitID)
2973 return nil
2974 }
2975
2976 quitDialog := dialog.NewQuit(m.com)
2977 m.dialog.OpenDialog(quitDialog)
2978 return nil
2979}
2980
2981// openModelsDialog opens the models dialog.
2982func (m *UI) openModelsDialog() tea.Cmd {
2983 if m.dialog.ContainsDialog(dialog.ModelsID) {
2984 // Bring to front
2985 m.dialog.BringToFront(dialog.ModelsID)
2986 return nil
2987 }
2988
2989 isOnboarding := m.state == uiOnboarding
2990 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2991 if err != nil {
2992 return util.ReportError(err)
2993 }
2994
2995 m.dialog.OpenDialog(modelsDialog)
2996
2997 return nil
2998}
2999
3000// openCommandsDialog opens the commands dialog.
3001func (m *UI) openCommandsDialog() tea.Cmd {
3002 if m.dialog.ContainsDialog(dialog.CommandsID) {
3003 // Bring to front
3004 m.dialog.BringToFront(dialog.CommandsID)
3005 return nil
3006 }
3007
3008 var sessionID string
3009 hasSession := m.session != nil
3010 if hasSession {
3011 sessionID = m.session.ID
3012 }
3013 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3014 hasQueue := m.promptQueue > 0
3015
3016 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3017 if err != nil {
3018 return util.ReportError(err)
3019 }
3020
3021 m.dialog.OpenDialog(commands)
3022
3023 return commands.InitialCmd()
3024}
3025
3026// openReasoningDialog opens the reasoning effort dialog.
3027func (m *UI) openReasoningDialog() tea.Cmd {
3028 if m.dialog.ContainsDialog(dialog.ReasoningID) {
3029 m.dialog.BringToFront(dialog.ReasoningID)
3030 return nil
3031 }
3032
3033 reasoningDialog, err := dialog.NewReasoning(m.com)
3034 if err != nil {
3035 return util.ReportError(err)
3036 }
3037
3038 m.dialog.OpenDialog(reasoningDialog)
3039 return nil
3040}
3041
3042// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3043// it brings it to the front. Otherwise, it will list all the sessions and open
3044// the dialog.
3045func (m *UI) openSessionsDialog() tea.Cmd {
3046 if m.dialog.ContainsDialog(dialog.SessionsID) {
3047 // Bring to front
3048 m.dialog.BringToFront(dialog.SessionsID)
3049 return nil
3050 }
3051
3052 selectedSessionID := ""
3053 if m.session != nil {
3054 selectedSessionID = m.session.ID
3055 }
3056
3057 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3058 if err != nil {
3059 return util.ReportError(err)
3060 }
3061
3062 m.dialog.OpenDialog(dialog)
3063 return nil
3064}
3065
3066// openFilesDialog opens the file picker dialog.
3067func (m *UI) openFilesDialog() tea.Cmd {
3068 if m.dialog.ContainsDialog(dialog.FilePickerID) {
3069 // Bring to front
3070 m.dialog.BringToFront(dialog.FilePickerID)
3071 return nil
3072 }
3073
3074 filePicker, cmd := dialog.NewFilePicker(m.com)
3075 filePicker.SetImageCapabilities(&m.caps)
3076 m.dialog.OpenDialog(filePicker)
3077
3078 return cmd
3079}
3080
3081// openPermissionsDialog opens the permissions dialog for a permission request.
3082func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3083 // Close any existing permissions dialog first.
3084 m.dialog.CloseDialog(dialog.PermissionsID)
3085
3086 // Get diff mode from config.
3087 var opts []dialog.PermissionsOption
3088 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3089 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3090 }
3091
3092 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3093 m.dialog.OpenDialog(permDialog)
3094 return nil
3095}
3096
3097// handlePermissionNotification updates tool items when permission state changes.
3098func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3099 toolItem := m.chat.MessageItem(notification.ToolCallID)
3100 if toolItem == nil {
3101 return
3102 }
3103
3104 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3105 if notification.Granted {
3106 permItem.SetStatus(chat.ToolStatusRunning)
3107 } else {
3108 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3109 }
3110 }
3111}
3112
3113// handleAgentNotification translates domain agent events into desktop
3114// notifications using the UI notification backend.
3115func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3116 switch n.Type {
3117 case notify.TypeAgentFinished:
3118 return m.sendNotification(notification.Notification{
3119 Title: "Crush is waiting...",
3120 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3121 })
3122 default:
3123 return nil
3124 }
3125}
3126
3127// newSession clears the current session state and prepares for a new session.
3128// The actual session creation happens when the user sends their first message.
3129// Returns a command to reload prompt history.
3130func (m *UI) newSession() tea.Cmd {
3131 if !m.hasSession() {
3132 return nil
3133 }
3134
3135 m.session = nil
3136 m.sessionFiles = nil
3137 m.sessionFileReads = nil
3138 m.setState(uiLanding, uiFocusEditor)
3139 m.textarea.Focus()
3140 m.chat.Blur()
3141 m.chat.ClearMessages()
3142 m.pillsExpanded = false
3143 m.promptQueue = 0
3144 m.pillsView = ""
3145 m.historyReset()
3146 agenttools.ResetCache()
3147 return tea.Batch(
3148 func() tea.Msg {
3149 m.com.App.LSPManager.StopAll(context.Background())
3150 return nil
3151 },
3152 m.loadPromptHistory(),
3153 )
3154}
3155
3156// handlePasteMsg handles a paste message.
3157func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3158 if m.dialog.HasDialogs() {
3159 return m.handleDialogMsg(msg)
3160 }
3161
3162 if m.focus != uiFocusEditor {
3163 return nil
3164 }
3165
3166 if hasPasteExceededThreshold(msg) {
3167 return func() tea.Msg {
3168 content := []byte(msg.Content)
3169 if int64(len(content)) > common.MaxAttachmentSize {
3170 return util.ReportWarn("Paste is too big (>5mb)")
3171 }
3172 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3173 mimeBufferSize := min(512, len(content))
3174 mimeType := http.DetectContentType(content[:mimeBufferSize])
3175 return message.Attachment{
3176 FileName: name,
3177 FilePath: name,
3178 MimeType: mimeType,
3179 Content: content,
3180 }
3181 }
3182 }
3183
3184 // Attempt to parse pasted content as file paths. If possible to parse,
3185 // all files exist and are valid, add as attachments.
3186 // Otherwise, paste as text.
3187 paths := fsext.ParsePastedFiles(msg.Content)
3188 allExistsAndValid := func() bool {
3189 if len(paths) == 0 {
3190 return false
3191 }
3192 for _, path := range paths {
3193 if _, err := os.Stat(path); os.IsNotExist(err) {
3194 return false
3195 }
3196
3197 lowerPath := strings.ToLower(path)
3198 isValid := false
3199 for _, ext := range common.AllowedImageTypes {
3200 if strings.HasSuffix(lowerPath, ext) {
3201 isValid = true
3202 break
3203 }
3204 }
3205 if !isValid {
3206 return false
3207 }
3208 }
3209 return true
3210 }
3211 if !allExistsAndValid() {
3212 var cmd tea.Cmd
3213 m.textarea, cmd = m.textarea.Update(msg)
3214 return cmd
3215 }
3216
3217 var cmds []tea.Cmd
3218 for _, path := range paths {
3219 cmds = append(cmds, m.handleFilePathPaste(path))
3220 }
3221 return tea.Batch(cmds...)
3222}
3223
3224func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3225 var (
3226 lineCount = 0
3227 colCount = 0
3228 )
3229 for line := range strings.SplitSeq(msg.Content, "\n") {
3230 lineCount++
3231 colCount = max(colCount, len(line))
3232
3233 if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3234 return true
3235 }
3236 }
3237 return false
3238}
3239
3240// handleFilePathPaste handles a pasted file path.
3241func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3242 return func() tea.Msg {
3243 fileInfo, err := os.Stat(path)
3244 if err != nil {
3245 return util.ReportError(err)
3246 }
3247 if fileInfo.IsDir() {
3248 return util.ReportWarn("Cannot attach a directory")
3249 }
3250 if fileInfo.Size() > common.MaxAttachmentSize {
3251 return util.ReportWarn("File is too big (>5mb)")
3252 }
3253
3254 content, err := os.ReadFile(path)
3255 if err != nil {
3256 return util.ReportError(err)
3257 }
3258
3259 mimeBufferSize := min(512, len(content))
3260 mimeType := http.DetectContentType(content[:mimeBufferSize])
3261 fileName := filepath.Base(path)
3262 return message.Attachment{
3263 FilePath: path,
3264 FileName: fileName,
3265 MimeType: mimeType,
3266 Content: content,
3267 }
3268 }
3269}
3270
3271// pasteImageFromClipboard reads image data from the system clipboard and
3272// creates an attachment. If no image data is found, it falls back to
3273// interpreting clipboard text as a file path.
3274func (m *UI) pasteImageFromClipboard() tea.Msg {
3275 imageData, err := readClipboard(clipboardFormatImage)
3276 if int64(len(imageData)) > common.MaxAttachmentSize {
3277 return util.InfoMsg{
3278 Type: util.InfoTypeError,
3279 Msg: "File too large, max 5MB",
3280 }
3281 }
3282 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3283 if err == nil {
3284 return message.Attachment{
3285 FilePath: name,
3286 FileName: name,
3287 MimeType: mimeOf(imageData),
3288 Content: imageData,
3289 }
3290 }
3291
3292 textData, textErr := readClipboard(clipboardFormatText)
3293 if textErr != nil || len(textData) == 0 {
3294 return nil // Clipboard is empty or does not contain an image
3295 }
3296
3297 path := strings.TrimSpace(string(textData))
3298 path = strings.ReplaceAll(path, "\\ ", " ")
3299 if _, statErr := os.Stat(path); statErr != nil {
3300 return nil // Clipboard does not contain an image or valid file path
3301 }
3302
3303 lowerPath := strings.ToLower(path)
3304 isAllowed := false
3305 for _, ext := range common.AllowedImageTypes {
3306 if strings.HasSuffix(lowerPath, ext) {
3307 isAllowed = true
3308 break
3309 }
3310 }
3311 if !isAllowed {
3312 return util.NewInfoMsg("File type is not a supported image format")
3313 }
3314
3315 fileInfo, statErr := os.Stat(path)
3316 if statErr != nil {
3317 return util.InfoMsg{
3318 Type: util.InfoTypeError,
3319 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3320 }
3321 }
3322 if fileInfo.Size() > common.MaxAttachmentSize {
3323 return util.InfoMsg{
3324 Type: util.InfoTypeError,
3325 Msg: "File too large, max 5MB",
3326 }
3327 }
3328
3329 content, readErr := os.ReadFile(path)
3330 if readErr != nil {
3331 return util.InfoMsg{
3332 Type: util.InfoTypeError,
3333 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3334 }
3335 }
3336
3337 return message.Attachment{
3338 FilePath: path,
3339 FileName: filepath.Base(path),
3340 MimeType: mimeOf(content),
3341 Content: content,
3342 }
3343}
3344
3345var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3346
3347func (m *UI) pasteIdx() int {
3348 result := 0
3349 for _, at := range m.attachments.List() {
3350 found := pasteRE.FindStringSubmatch(at.FileName)
3351 if len(found) == 0 {
3352 continue
3353 }
3354 idx, err := strconv.Atoi(found[1])
3355 if err == nil {
3356 result = max(result, idx)
3357 }
3358 }
3359 return result + 1
3360}
3361
3362// drawSessionDetails draws the session details in compact mode.
3363func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3364 if m.session == nil {
3365 return
3366 }
3367
3368 s := m.com.Styles
3369
3370 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3371 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3372
3373 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3374 blocks := []string{
3375 title,
3376 "",
3377 m.modelInfo(width),
3378 "",
3379 }
3380
3381 detailsHeader := lipgloss.JoinVertical(
3382 lipgloss.Left,
3383 blocks...,
3384 )
3385
3386 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3387
3388 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3389
3390 const maxSectionWidth = 50
3391 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3392 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3393
3394 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3395 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3396 filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3397 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3398 uv.NewStyledString(
3399 s.CompactDetails.View.
3400 Width(area.Dx()).
3401 Render(
3402 lipgloss.JoinVertical(
3403 lipgloss.Left,
3404 detailsHeader,
3405 sections,
3406 version,
3407 ),
3408 ),
3409 ).Draw(scr, area)
3410}
3411
3412func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3413 load := func() tea.Msg {
3414 prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3415 if err != nil {
3416 // TODO: make this better
3417 return util.ReportError(err)()
3418 }
3419
3420 if prompt == "" {
3421 return nil
3422 }
3423 return sendMessageMsg{
3424 Content: prompt,
3425 }
3426 }
3427
3428 var cmds []tea.Cmd
3429 if cmd := m.dialog.StartLoading(); cmd != nil {
3430 cmds = append(cmds, cmd)
3431 }
3432 cmds = append(cmds, load, func() tea.Msg {
3433 return closeDialogMsg{}
3434 })
3435
3436 return tea.Sequence(cmds...)
3437}
3438
3439func (m *UI) handleStateChanged() tea.Cmd {
3440 return func() tea.Msg {
3441 m.com.App.UpdateAgentModel(context.Background())
3442 return mcpStateChangedMsg{
3443 states: mcp.GetStates(),
3444 }
3445 }
3446}
3447
3448func handleMCPPromptsEvent(name string) tea.Cmd {
3449 return func() tea.Msg {
3450 mcp.RefreshPrompts(context.Background(), name)
3451 return nil
3452 }
3453}
3454
3455func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3456 return func() tea.Msg {
3457 mcp.RefreshTools(
3458 context.Background(),
3459 cfg,
3460 name,
3461 )
3462 return nil
3463 }
3464}
3465
3466func handleMCPResourcesEvent(name string) tea.Cmd {
3467 return func() tea.Msg {
3468 mcp.RefreshResources(context.Background(), name)
3469 return nil
3470 }
3471}
3472
3473func (m *UI) copyChatHighlight() tea.Cmd {
3474 text := m.chat.HighlightContent()
3475 return common.CopyToClipboardWithCallback(
3476 text,
3477 "Selected text copied to clipboard",
3478 func() tea.Msg {
3479 m.chat.ClearMouse()
3480 return nil
3481 },
3482 )
3483}
3484
3485func (m *UI) enableDockerMCP() tea.Msg {
3486 store := m.com.Store()
3487 // Stage Docker MCP in memory first so startup and persistence can be atomic.
3488 mcpConfig, err := store.PrepareDockerMCPConfig()
3489 if err != nil {
3490 return util.ReportError(err)()
3491 }
3492
3493 ctx := context.Background()
3494 if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3495 // Roll back runtime and in-memory state when startup fails.
3496 disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3497 delete(store.Config().MCP, config.DockerMCPName)
3498 return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
3499 }
3500
3501 if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
3502 // Roll back runtime and in-memory state if persistence fails.
3503 disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3504 delete(store.Config().MCP, config.DockerMCPName)
3505 return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
3506 }
3507
3508 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3509}
3510
3511func (m *UI) disableDockerMCP() tea.Msg {
3512 store := m.com.Store()
3513 // Close the Docker MCP client.
3514 if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3515 return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3516 }
3517
3518 // Remove from config and persist.
3519 if err := store.DisableDockerMCP(); err != nil {
3520 return util.ReportError(err)()
3521 }
3522
3523 return util.NewInfoMsg("Docker MCP disabled successfully")
3524}
3525
3526// renderLogo renders the Crush logo with the given styles and dimensions.
3527func renderLogo(t *styles.Styles, compact bool, width int) string {
3528 return logo.Render(t, version.Version, compact, logo.Opts{
3529 FieldColor: t.LogoFieldColor,
3530 TitleColorA: t.LogoTitleColorA,
3531 TitleColorB: t.LogoTitleColorB,
3532 CharmColor: t.LogoCharmColor,
3533 VersionColor: t.LogoVersionColor,
3534 Width: width,
3535 })
3536}