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 }
2315 }
2316
2317 binds = append(binds,
2318 []key.Binding{
2319 help,
2320 k.Quit,
2321 },
2322 )
2323
2324 return binds
2325}
2326
2327// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2328func (m *UI) toggleCompactMode() tea.Cmd {
2329 m.forceCompactMode = !m.forceCompactMode
2330
2331 err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2332 if err != nil {
2333 return util.ReportError(err)
2334 }
2335
2336 m.updateLayoutAndSize()
2337
2338 return nil
2339}
2340
2341// updateLayoutAndSize updates the layout and sizes of UI components.
2342func (m *UI) updateLayoutAndSize() {
2343 // Determine if we should be in compact mode
2344 if m.state == uiChat {
2345 if m.forceCompactMode {
2346 m.isCompact = true
2347 return
2348 }
2349 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2350 m.isCompact = true
2351 } else {
2352 m.isCompact = false
2353 }
2354 }
2355
2356 m.layout = m.generateLayout(m.width, m.height)
2357 m.updateSize()
2358}
2359
2360// updateSize updates the sizes of UI components based on the current layout.
2361func (m *UI) updateSize() {
2362 // Set status width
2363 m.status.SetWidth(m.layout.status.Dx())
2364
2365 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2366 m.textarea.SetWidth(m.layout.editor.Dx())
2367 // TODO: Abstract the textarea and attachments into a single editor
2368 // component so we don't have to manually account for the attachments
2369 // height here.
2370 m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2371 m.renderPills()
2372
2373 // Handle different app states
2374 switch m.state {
2375 case uiChat:
2376 if !m.isCompact {
2377 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2378 }
2379 }
2380}
2381
2382// generateLayout calculates the layout rectangles for all UI components based
2383// on the current UI state and terminal dimensions.
2384func (m *UI) generateLayout(w, h int) uiLayout {
2385 // The screen area we're working with
2386 area := image.Rect(0, 0, w, h)
2387
2388 // The help height
2389 helpHeight := 1
2390 // The editor height
2391 editorHeight := 5
2392 // The sidebar width
2393 sidebarWidth := 30
2394 // The header height
2395 const landingHeaderHeight = 4
2396
2397 var helpKeyMap help.KeyMap = m
2398 if m.status != nil && m.status.ShowingAll() {
2399 for _, row := range helpKeyMap.FullHelp() {
2400 helpHeight = max(helpHeight, len(row))
2401 }
2402 }
2403
2404 // Add app margins
2405 appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2406 appRect.Min.Y += 1
2407 appRect.Max.Y -= 1
2408 helpRect.Min.Y -= 1
2409 appRect.Min.X += 1
2410 appRect.Max.X -= 1
2411
2412 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2413 // extra padding on left and right for these states
2414 appRect.Min.X += 1
2415 appRect.Max.X -= 1
2416 }
2417
2418 uiLayout := uiLayout{
2419 area: area,
2420 status: helpRect,
2421 }
2422
2423 // Handle different app states
2424 switch m.state {
2425 case uiOnboarding, uiInitialize:
2426 // Layout
2427 //
2428 // header
2429 // ------
2430 // main
2431 // ------
2432 // help
2433
2434 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2435 uiLayout.header = headerRect
2436 uiLayout.main = mainRect
2437
2438 case uiLanding:
2439 // Layout
2440 //
2441 // header
2442 // ------
2443 // main
2444 // ------
2445 // editor
2446 // ------
2447 // help
2448 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2449 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2450 // Remove extra padding from editor (but keep it for header and main)
2451 editorRect.Min.X -= 1
2452 editorRect.Max.X += 1
2453 uiLayout.header = headerRect
2454 uiLayout.main = mainRect
2455 uiLayout.editor = editorRect
2456
2457 case uiChat:
2458 if m.isCompact {
2459 // Layout
2460 //
2461 // compact-header
2462 // ------
2463 // main
2464 // ------
2465 // editor
2466 // ------
2467 // help
2468 const compactHeaderHeight = 1
2469 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2470 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2471 sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2472 uiLayout.sessionDetails = sessionDetailsArea
2473 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2474 // Add one line gap between header and main content
2475 mainRect.Min.Y += 1
2476 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2477 mainRect.Max.X -= 1 // Add padding right
2478 uiLayout.header = headerRect
2479 pillsHeight := m.pillsAreaHeight()
2480 if pillsHeight > 0 {
2481 pillsHeight = min(pillsHeight, mainRect.Dy())
2482 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2483 uiLayout.main = chatRect
2484 uiLayout.pills = pillsRect
2485 } else {
2486 uiLayout.main = mainRect
2487 }
2488 // Add bottom margin to main
2489 uiLayout.main.Max.Y -= 1
2490 uiLayout.editor = editorRect
2491 } else {
2492 // Layout
2493 //
2494 // ------|---
2495 // main |
2496 // ------| side
2497 // editor|
2498 // ----------
2499 // help
2500
2501 mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2502 // Add padding left
2503 sideRect.Min.X += 1
2504 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2505 mainRect.Max.X -= 1 // Add padding right
2506 uiLayout.sidebar = sideRect
2507 pillsHeight := m.pillsAreaHeight()
2508 if pillsHeight > 0 {
2509 pillsHeight = min(pillsHeight, mainRect.Dy())
2510 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2511 uiLayout.main = chatRect
2512 uiLayout.pills = pillsRect
2513 } else {
2514 uiLayout.main = mainRect
2515 }
2516 // Add bottom margin to main
2517 uiLayout.main.Max.Y -= 1
2518 uiLayout.editor = editorRect
2519 }
2520 }
2521
2522 return uiLayout
2523}
2524
2525// uiLayout defines the positioning of UI elements.
2526type uiLayout struct {
2527 // area is the overall available area.
2528 area uv.Rectangle
2529
2530 // header is the header shown in special cases
2531 // e.x when the sidebar is collapsed
2532 // or when in the landing page
2533 // or in init/config
2534 header uv.Rectangle
2535
2536 // main is the area for the main pane. (e.x chat, configure, landing)
2537 main uv.Rectangle
2538
2539 // pills is the area for the pills panel.
2540 pills uv.Rectangle
2541
2542 // editor is the area for the editor pane.
2543 editor uv.Rectangle
2544
2545 // sidebar is the area for the sidebar.
2546 sidebar uv.Rectangle
2547
2548 // status is the area for the status view.
2549 status uv.Rectangle
2550
2551 // session details is the area for the session details overlay in compact mode.
2552 sessionDetails uv.Rectangle
2553}
2554
2555func (m *UI) openEditor(value string) tea.Cmd {
2556 tmpfile, err := os.CreateTemp("", "msg_*.md")
2557 if err != nil {
2558 return util.ReportError(err)
2559 }
2560 defer tmpfile.Close() //nolint:errcheck
2561 if _, err := tmpfile.WriteString(value); err != nil {
2562 return util.ReportError(err)
2563 }
2564 cmd, err := editor.Command(
2565 "crush",
2566 tmpfile.Name(),
2567 editor.AtPosition(
2568 m.textarea.Line()+1,
2569 m.textarea.Column()+1,
2570 ),
2571 )
2572 if err != nil {
2573 return util.ReportError(err)
2574 }
2575 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2576 if err != nil {
2577 return util.ReportError(err)
2578 }
2579 content, err := os.ReadFile(tmpfile.Name())
2580 if err != nil {
2581 return util.ReportError(err)
2582 }
2583 if len(content) == 0 {
2584 return util.ReportWarn("Message is empty")
2585 }
2586 os.Remove(tmpfile.Name())
2587 return openEditorMsg{
2588 Text: strings.TrimSpace(string(content)),
2589 }
2590 })
2591}
2592
2593// setEditorPrompt configures the textarea prompt function based on whether
2594// yolo mode is enabled.
2595func (m *UI) setEditorPrompt(yolo bool) {
2596 if yolo {
2597 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2598 return
2599 }
2600 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2601}
2602
2603// normalPromptFunc returns the normal editor prompt style (" > " on first
2604// line, "::: " on subsequent lines).
2605func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2606 t := m.com.Styles
2607 if info.LineNumber == 0 {
2608 if info.Focused {
2609 return " > "
2610 }
2611 return "::: "
2612 }
2613 if info.Focused {
2614 return t.EditorPromptNormalFocused.Render()
2615 }
2616 return t.EditorPromptNormalBlurred.Render()
2617}
2618
2619// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2620// and colored dots.
2621func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2622 t := m.com.Styles
2623 if info.LineNumber == 0 {
2624 if info.Focused {
2625 return t.EditorPromptYoloIconFocused.Render()
2626 } else {
2627 return t.EditorPromptYoloIconBlurred.Render()
2628 }
2629 }
2630 if info.Focused {
2631 return t.EditorPromptYoloDotsFocused.Render()
2632 }
2633 return t.EditorPromptYoloDotsBlurred.Render()
2634}
2635
2636// closeCompletions closes the completions popup and resets state.
2637func (m *UI) closeCompletions() {
2638 m.completionsOpen = false
2639 m.completionsQuery = ""
2640 m.completionsStartIndex = 0
2641 m.completions.Close()
2642}
2643
2644// insertCompletionText replaces the @query in the textarea with the given text.
2645// Returns false if the replacement cannot be performed.
2646func (m *UI) insertCompletionText(text string) bool {
2647 value := m.textarea.Value()
2648 if m.completionsStartIndex > len(value) {
2649 return false
2650 }
2651
2652 word := m.textareaWord()
2653 endIdx := min(m.completionsStartIndex+len(word), len(value))
2654 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2655 m.textarea.SetValue(newValue)
2656 m.textarea.MoveToEnd()
2657 m.textarea.InsertRune(' ')
2658 return true
2659}
2660
2661// insertFileCompletion inserts the selected file path into the textarea,
2662// replacing the @query, and adds the file as an attachment.
2663func (m *UI) insertFileCompletion(path string) tea.Cmd {
2664 if !m.insertCompletionText(path) {
2665 return nil
2666 }
2667
2668 return func() tea.Msg {
2669 absPath, _ := filepath.Abs(path)
2670
2671 if m.hasSession() {
2672 // Skip attachment if file was already read and hasn't been modified.
2673 lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2674 if !lastRead.IsZero() {
2675 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2676 return nil
2677 }
2678 }
2679 } else if slices.Contains(m.sessionFileReads, absPath) {
2680 return nil
2681 }
2682
2683 m.sessionFileReads = append(m.sessionFileReads, absPath)
2684
2685 // Add file as attachment.
2686 content, err := os.ReadFile(path)
2687 if err != nil {
2688 // If it fails, let the LLM handle it later.
2689 return nil
2690 }
2691
2692 return message.Attachment{
2693 FilePath: path,
2694 FileName: filepath.Base(path),
2695 MimeType: mimeOf(content),
2696 Content: content,
2697 }
2698 }
2699}
2700
2701// insertMCPResourceCompletion inserts the selected resource into the textarea,
2702// replacing the @query, and adds the resource as an attachment.
2703func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2704 displayText := cmp.Or(item.Title, item.URI)
2705
2706 if !m.insertCompletionText(displayText) {
2707 return nil
2708 }
2709
2710 return func() tea.Msg {
2711 contents, err := mcp.ReadResource(
2712 context.Background(),
2713 m.com.Store(),
2714 item.MCPName,
2715 item.URI,
2716 )
2717 if err != nil {
2718 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2719 return nil
2720 }
2721 if len(contents) == 0 {
2722 return nil
2723 }
2724
2725 content := contents[0]
2726 var data []byte
2727 if content.Text != "" {
2728 data = []byte(content.Text)
2729 } else if len(content.Blob) > 0 {
2730 data = content.Blob
2731 }
2732 if len(data) == 0 {
2733 return nil
2734 }
2735
2736 mimeType := item.MIMEType
2737 if mimeType == "" && content.MIMEType != "" {
2738 mimeType = content.MIMEType
2739 }
2740 if mimeType == "" {
2741 mimeType = "text/plain"
2742 }
2743
2744 return message.Attachment{
2745 FilePath: item.URI,
2746 FileName: displayText,
2747 MimeType: mimeType,
2748 Content: data,
2749 }
2750 }
2751}
2752
2753// completionsPosition returns the X and Y position for the completions popup.
2754func (m *UI) completionsPosition() image.Point {
2755 cur := m.textarea.Cursor()
2756 if cur == nil {
2757 return image.Point{
2758 X: m.layout.editor.Min.X,
2759 Y: m.layout.editor.Min.Y,
2760 }
2761 }
2762 return image.Point{
2763 X: cur.X + m.layout.editor.Min.X,
2764 Y: m.layout.editor.Min.Y + cur.Y,
2765 }
2766}
2767
2768// textareaWord returns the current word at the cursor position.
2769func (m *UI) textareaWord() string {
2770 return m.textarea.Word()
2771}
2772
2773// isWhitespace returns true if the byte is a whitespace character.
2774func isWhitespace(b byte) bool {
2775 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2776}
2777
2778// isAgentBusy returns true if the agent coordinator exists and is currently
2779// busy processing a request.
2780func (m *UI) isAgentBusy() bool {
2781 return m.com.App != nil &&
2782 m.com.App.AgentCoordinator != nil &&
2783 m.com.App.AgentCoordinator.IsBusy()
2784}
2785
2786// hasSession returns true if there is an active session with a valid ID.
2787func (m *UI) hasSession() bool {
2788 return m.session != nil && m.session.ID != ""
2789}
2790
2791// mimeOf detects the MIME type of the given content.
2792func mimeOf(content []byte) string {
2793 mimeBufferSize := min(512, len(content))
2794 return http.DetectContentType(content[:mimeBufferSize])
2795}
2796
2797var readyPlaceholders = [...]string{
2798 "Ready!",
2799 "Ready...",
2800 "Ready?",
2801 "Ready for instructions",
2802}
2803
2804var workingPlaceholders = [...]string{
2805 "Working!",
2806 "Working...",
2807 "Brrrrr...",
2808 "Prrrrrrrr...",
2809 "Processing...",
2810 "Thinking...",
2811}
2812
2813// randomizePlaceholders selects random placeholder text for the textarea's
2814// ready and working states.
2815func (m *UI) randomizePlaceholders() {
2816 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2817 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2818}
2819
2820// renderEditorView renders the editor view with attachments if any.
2821func (m *UI) renderEditorView(width int) string {
2822 var attachmentsView string
2823 if len(m.attachments.List()) > 0 {
2824 attachmentsView = m.attachments.Render(width)
2825 }
2826 return strings.Join([]string{
2827 attachmentsView,
2828 m.textarea.View(),
2829 "", // margin at bottom of editor
2830 }, "\n")
2831}
2832
2833// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2834func (m *UI) cacheSidebarLogo(width int) {
2835 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2836}
2837
2838// sendMessage sends a message with the given content and attachments.
2839func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2840 if m.com.App.AgentCoordinator == nil {
2841 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2842 }
2843
2844 var cmds []tea.Cmd
2845 if !m.hasSession() {
2846 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2847 if err != nil {
2848 return util.ReportError(err)
2849 }
2850 if m.forceCompactMode {
2851 m.isCompact = true
2852 }
2853 if newSession.ID != "" {
2854 m.session = &newSession
2855 cmds = append(cmds, m.loadSession(newSession.ID))
2856 }
2857 m.setState(uiChat, m.focus)
2858 }
2859
2860 ctx := context.Background()
2861 cmds = append(cmds, func() tea.Msg {
2862 for _, path := range m.sessionFileReads {
2863 m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2864 m.com.App.LSPManager.Start(ctx, path)
2865 }
2866 return nil
2867 })
2868
2869 // Capture session ID to avoid race with main goroutine updating m.session.
2870 sessionID := m.session.ID
2871 cmds = append(cmds, func() tea.Msg {
2872 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2873 if err != nil {
2874 isCancelErr := errors.Is(err, context.Canceled)
2875 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2876 if isCancelErr || isPermissionErr {
2877 return nil
2878 }
2879 return util.InfoMsg{
2880 Type: util.InfoTypeError,
2881 Msg: err.Error(),
2882 }
2883 }
2884 return nil
2885 })
2886 return tea.Batch(cmds...)
2887}
2888
2889const cancelTimerDuration = 2 * time.Second
2890
2891// cancelTimerCmd creates a command that expires the cancel timer.
2892func cancelTimerCmd() tea.Cmd {
2893 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2894 return cancelTimerExpiredMsg{}
2895 })
2896}
2897
2898// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2899// and starts a timer. The second press (before the timer expires) actually
2900// cancels the agent.
2901func (m *UI) cancelAgent() tea.Cmd {
2902 if !m.hasSession() {
2903 return nil
2904 }
2905
2906 coordinator := m.com.App.AgentCoordinator
2907 if coordinator == nil {
2908 return nil
2909 }
2910
2911 if m.isCanceling {
2912 // Second escape press - actually cancel the agent.
2913 m.isCanceling = false
2914 coordinator.Cancel(m.session.ID)
2915 // Stop the spinning todo indicator.
2916 m.todoIsSpinning = false
2917 m.renderPills()
2918 return nil
2919 }
2920
2921 // Check if there are queued prompts - if so, clear the queue.
2922 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2923 coordinator.ClearQueue(m.session.ID)
2924 return nil
2925 }
2926
2927 // First escape press - set canceling state and start timer.
2928 m.isCanceling = true
2929 return cancelTimerCmd()
2930}
2931
2932// openDialog opens a dialog by its ID.
2933func (m *UI) openDialog(id string) tea.Cmd {
2934 var cmds []tea.Cmd
2935 switch id {
2936 case dialog.SessionsID:
2937 if cmd := m.openSessionsDialog(); cmd != nil {
2938 cmds = append(cmds, cmd)
2939 }
2940 case dialog.ModelsID:
2941 if cmd := m.openModelsDialog(); cmd != nil {
2942 cmds = append(cmds, cmd)
2943 }
2944 case dialog.CommandsID:
2945 if cmd := m.openCommandsDialog(); cmd != nil {
2946 cmds = append(cmds, cmd)
2947 }
2948 case dialog.ReasoningID:
2949 if cmd := m.openReasoningDialog(); cmd != nil {
2950 cmds = append(cmds, cmd)
2951 }
2952 case dialog.QuitID:
2953 if cmd := m.openQuitDialog(); cmd != nil {
2954 cmds = append(cmds, cmd)
2955 }
2956 default:
2957 // Unknown dialog
2958 break
2959 }
2960 return tea.Batch(cmds...)
2961}
2962
2963// openQuitDialog opens the quit confirmation dialog.
2964func (m *UI) openQuitDialog() tea.Cmd {
2965 if m.dialog.ContainsDialog(dialog.QuitID) {
2966 // Bring to front
2967 m.dialog.BringToFront(dialog.QuitID)
2968 return nil
2969 }
2970
2971 quitDialog := dialog.NewQuit(m.com)
2972 m.dialog.OpenDialog(quitDialog)
2973 return nil
2974}
2975
2976// openModelsDialog opens the models dialog.
2977func (m *UI) openModelsDialog() tea.Cmd {
2978 if m.dialog.ContainsDialog(dialog.ModelsID) {
2979 // Bring to front
2980 m.dialog.BringToFront(dialog.ModelsID)
2981 return nil
2982 }
2983
2984 isOnboarding := m.state == uiOnboarding
2985 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2986 if err != nil {
2987 return util.ReportError(err)
2988 }
2989
2990 m.dialog.OpenDialog(modelsDialog)
2991
2992 return nil
2993}
2994
2995// openCommandsDialog opens the commands dialog.
2996func (m *UI) openCommandsDialog() tea.Cmd {
2997 if m.dialog.ContainsDialog(dialog.CommandsID) {
2998 // Bring to front
2999 m.dialog.BringToFront(dialog.CommandsID)
3000 return nil
3001 }
3002
3003 var sessionID string
3004 hasSession := m.session != nil
3005 if hasSession {
3006 sessionID = m.session.ID
3007 }
3008 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3009 hasQueue := m.promptQueue > 0
3010
3011 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3012 if err != nil {
3013 return util.ReportError(err)
3014 }
3015
3016 m.dialog.OpenDialog(commands)
3017
3018 return commands.InitialCmd()
3019}
3020
3021// openReasoningDialog opens the reasoning effort dialog.
3022func (m *UI) openReasoningDialog() tea.Cmd {
3023 if m.dialog.ContainsDialog(dialog.ReasoningID) {
3024 m.dialog.BringToFront(dialog.ReasoningID)
3025 return nil
3026 }
3027
3028 reasoningDialog, err := dialog.NewReasoning(m.com)
3029 if err != nil {
3030 return util.ReportError(err)
3031 }
3032
3033 m.dialog.OpenDialog(reasoningDialog)
3034 return nil
3035}
3036
3037// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3038// it brings it to the front. Otherwise, it will list all the sessions and open
3039// the dialog.
3040func (m *UI) openSessionsDialog() tea.Cmd {
3041 if m.dialog.ContainsDialog(dialog.SessionsID) {
3042 // Bring to front
3043 m.dialog.BringToFront(dialog.SessionsID)
3044 return nil
3045 }
3046
3047 selectedSessionID := ""
3048 if m.session != nil {
3049 selectedSessionID = m.session.ID
3050 }
3051
3052 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3053 if err != nil {
3054 return util.ReportError(err)
3055 }
3056
3057 m.dialog.OpenDialog(dialog)
3058 return nil
3059}
3060
3061// openFilesDialog opens the file picker dialog.
3062func (m *UI) openFilesDialog() tea.Cmd {
3063 if m.dialog.ContainsDialog(dialog.FilePickerID) {
3064 // Bring to front
3065 m.dialog.BringToFront(dialog.FilePickerID)
3066 return nil
3067 }
3068
3069 filePicker, cmd := dialog.NewFilePicker(m.com)
3070 filePicker.SetImageCapabilities(&m.caps)
3071 m.dialog.OpenDialog(filePicker)
3072
3073 return cmd
3074}
3075
3076// openPermissionsDialog opens the permissions dialog for a permission request.
3077func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3078 // Close any existing permissions dialog first.
3079 m.dialog.CloseDialog(dialog.PermissionsID)
3080
3081 // Get diff mode from config.
3082 var opts []dialog.PermissionsOption
3083 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3084 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3085 }
3086
3087 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3088 m.dialog.OpenDialog(permDialog)
3089 return nil
3090}
3091
3092// handlePermissionNotification updates tool items when permission state changes.
3093func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3094 toolItem := m.chat.MessageItem(notification.ToolCallID)
3095 if toolItem == nil {
3096 return
3097 }
3098
3099 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3100 if notification.Granted {
3101 permItem.SetStatus(chat.ToolStatusRunning)
3102 } else {
3103 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3104 }
3105 }
3106}
3107
3108// handleAgentNotification translates domain agent events into desktop
3109// notifications using the UI notification backend.
3110func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3111 switch n.Type {
3112 case notify.TypeAgentFinished:
3113 return m.sendNotification(notification.Notification{
3114 Title: "Crush is waiting...",
3115 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3116 })
3117 default:
3118 return nil
3119 }
3120}
3121
3122// newSession clears the current session state and prepares for a new session.
3123// The actual session creation happens when the user sends their first message.
3124// Returns a command to reload prompt history.
3125func (m *UI) newSession() tea.Cmd {
3126 if !m.hasSession() {
3127 return nil
3128 }
3129
3130 m.session = nil
3131 m.sessionFiles = nil
3132 m.sessionFileReads = nil
3133 m.setState(uiLanding, uiFocusEditor)
3134 m.textarea.Focus()
3135 m.chat.Blur()
3136 m.chat.ClearMessages()
3137 m.pillsExpanded = false
3138 m.promptQueue = 0
3139 m.pillsView = ""
3140 m.historyReset()
3141 agenttools.ResetCache()
3142 return tea.Batch(
3143 func() tea.Msg {
3144 m.com.App.LSPManager.StopAll(context.Background())
3145 return nil
3146 },
3147 m.loadPromptHistory(),
3148 )
3149}
3150
3151// handlePasteMsg handles a paste message.
3152func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3153 if m.dialog.HasDialogs() {
3154 return m.handleDialogMsg(msg)
3155 }
3156
3157 if m.focus != uiFocusEditor {
3158 return nil
3159 }
3160
3161 if hasPasteExceededThreshold(msg) {
3162 return func() tea.Msg {
3163 content := []byte(msg.Content)
3164 if int64(len(content)) > common.MaxAttachmentSize {
3165 return util.ReportWarn("Paste is too big (>5mb)")
3166 }
3167 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3168 mimeBufferSize := min(512, len(content))
3169 mimeType := http.DetectContentType(content[:mimeBufferSize])
3170 return message.Attachment{
3171 FileName: name,
3172 FilePath: name,
3173 MimeType: mimeType,
3174 Content: content,
3175 }
3176 }
3177 }
3178
3179 // Attempt to parse pasted content as file paths. If possible to parse,
3180 // all files exist and are valid, add as attachments.
3181 // Otherwise, paste as text.
3182 paths := fsext.ParsePastedFiles(msg.Content)
3183 allExistsAndValid := func() bool {
3184 if len(paths) == 0 {
3185 return false
3186 }
3187 for _, path := range paths {
3188 if _, err := os.Stat(path); os.IsNotExist(err) {
3189 return false
3190 }
3191
3192 lowerPath := strings.ToLower(path)
3193 isValid := false
3194 for _, ext := range common.AllowedImageTypes {
3195 if strings.HasSuffix(lowerPath, ext) {
3196 isValid = true
3197 break
3198 }
3199 }
3200 if !isValid {
3201 return false
3202 }
3203 }
3204 return true
3205 }
3206 if !allExistsAndValid() {
3207 var cmd tea.Cmd
3208 m.textarea, cmd = m.textarea.Update(msg)
3209 return cmd
3210 }
3211
3212 var cmds []tea.Cmd
3213 for _, path := range paths {
3214 cmds = append(cmds, m.handleFilePathPaste(path))
3215 }
3216 return tea.Batch(cmds...)
3217}
3218
3219func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3220 var (
3221 lineCount = 0
3222 colCount = 0
3223 )
3224 for line := range strings.SplitSeq(msg.Content, "\n") {
3225 lineCount++
3226 colCount = max(colCount, len(line))
3227
3228 if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3229 return true
3230 }
3231 }
3232 return false
3233}
3234
3235// handleFilePathPaste handles a pasted file path.
3236func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3237 return func() tea.Msg {
3238 fileInfo, err := os.Stat(path)
3239 if err != nil {
3240 return util.ReportError(err)
3241 }
3242 if fileInfo.IsDir() {
3243 return util.ReportWarn("Cannot attach a directory")
3244 }
3245 if fileInfo.Size() > common.MaxAttachmentSize {
3246 return util.ReportWarn("File is too big (>5mb)")
3247 }
3248
3249 content, err := os.ReadFile(path)
3250 if err != nil {
3251 return util.ReportError(err)
3252 }
3253
3254 mimeBufferSize := min(512, len(content))
3255 mimeType := http.DetectContentType(content[:mimeBufferSize])
3256 fileName := filepath.Base(path)
3257 return message.Attachment{
3258 FilePath: path,
3259 FileName: fileName,
3260 MimeType: mimeType,
3261 Content: content,
3262 }
3263 }
3264}
3265
3266// pasteImageFromClipboard reads image data from the system clipboard and
3267// creates an attachment. If no image data is found, it falls back to
3268// interpreting clipboard text as a file path.
3269func (m *UI) pasteImageFromClipboard() tea.Msg {
3270 imageData, err := readClipboard(clipboardFormatImage)
3271 if int64(len(imageData)) > common.MaxAttachmentSize {
3272 return util.InfoMsg{
3273 Type: util.InfoTypeError,
3274 Msg: "File too large, max 5MB",
3275 }
3276 }
3277 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3278 if err == nil {
3279 return message.Attachment{
3280 FilePath: name,
3281 FileName: name,
3282 MimeType: mimeOf(imageData),
3283 Content: imageData,
3284 }
3285 }
3286
3287 textData, textErr := readClipboard(clipboardFormatText)
3288 if textErr != nil || len(textData) == 0 {
3289 return nil // Clipboard is empty or does not contain an image
3290 }
3291
3292 path := strings.TrimSpace(string(textData))
3293 path = strings.ReplaceAll(path, "\\ ", " ")
3294 if _, statErr := os.Stat(path); statErr != nil {
3295 return nil // Clipboard does not contain an image or valid file path
3296 }
3297
3298 lowerPath := strings.ToLower(path)
3299 isAllowed := false
3300 for _, ext := range common.AllowedImageTypes {
3301 if strings.HasSuffix(lowerPath, ext) {
3302 isAllowed = true
3303 break
3304 }
3305 }
3306 if !isAllowed {
3307 return util.NewInfoMsg("File type is not a supported image format")
3308 }
3309
3310 fileInfo, statErr := os.Stat(path)
3311 if statErr != nil {
3312 return util.InfoMsg{
3313 Type: util.InfoTypeError,
3314 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3315 }
3316 }
3317 if fileInfo.Size() > common.MaxAttachmentSize {
3318 return util.InfoMsg{
3319 Type: util.InfoTypeError,
3320 Msg: "File too large, max 5MB",
3321 }
3322 }
3323
3324 content, readErr := os.ReadFile(path)
3325 if readErr != nil {
3326 return util.InfoMsg{
3327 Type: util.InfoTypeError,
3328 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3329 }
3330 }
3331
3332 return message.Attachment{
3333 FilePath: path,
3334 FileName: filepath.Base(path),
3335 MimeType: mimeOf(content),
3336 Content: content,
3337 }
3338}
3339
3340var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3341
3342func (m *UI) pasteIdx() int {
3343 result := 0
3344 for _, at := range m.attachments.List() {
3345 found := pasteRE.FindStringSubmatch(at.FileName)
3346 if len(found) == 0 {
3347 continue
3348 }
3349 idx, err := strconv.Atoi(found[1])
3350 if err == nil {
3351 result = max(result, idx)
3352 }
3353 }
3354 return result + 1
3355}
3356
3357// drawSessionDetails draws the session details in compact mode.
3358func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3359 if m.session == nil {
3360 return
3361 }
3362
3363 s := m.com.Styles
3364
3365 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3366 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3367
3368 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3369 blocks := []string{
3370 title,
3371 "",
3372 m.modelInfo(width),
3373 "",
3374 }
3375
3376 detailsHeader := lipgloss.JoinVertical(
3377 lipgloss.Left,
3378 blocks...,
3379 )
3380
3381 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3382
3383 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3384
3385 const maxSectionWidth = 50
3386 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3387 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3388
3389 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3390 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3391 filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3392 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3393 uv.NewStyledString(
3394 s.CompactDetails.View.
3395 Width(area.Dx()).
3396 Render(
3397 lipgloss.JoinVertical(
3398 lipgloss.Left,
3399 detailsHeader,
3400 sections,
3401 version,
3402 ),
3403 ),
3404 ).Draw(scr, area)
3405}
3406
3407func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3408 load := func() tea.Msg {
3409 prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3410 if err != nil {
3411 // TODO: make this better
3412 return util.ReportError(err)()
3413 }
3414
3415 if prompt == "" {
3416 return nil
3417 }
3418 return sendMessageMsg{
3419 Content: prompt,
3420 }
3421 }
3422
3423 var cmds []tea.Cmd
3424 if cmd := m.dialog.StartLoading(); cmd != nil {
3425 cmds = append(cmds, cmd)
3426 }
3427 cmds = append(cmds, load, func() tea.Msg {
3428 return closeDialogMsg{}
3429 })
3430
3431 return tea.Sequence(cmds...)
3432}
3433
3434func (m *UI) handleStateChanged() tea.Cmd {
3435 return func() tea.Msg {
3436 m.com.App.UpdateAgentModel(context.Background())
3437 return mcpStateChangedMsg{
3438 states: mcp.GetStates(),
3439 }
3440 }
3441}
3442
3443func handleMCPPromptsEvent(name string) tea.Cmd {
3444 return func() tea.Msg {
3445 mcp.RefreshPrompts(context.Background(), name)
3446 return nil
3447 }
3448}
3449
3450func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3451 return func() tea.Msg {
3452 mcp.RefreshTools(
3453 context.Background(),
3454 cfg,
3455 name,
3456 )
3457 return nil
3458 }
3459}
3460
3461func handleMCPResourcesEvent(name string) tea.Cmd {
3462 return func() tea.Msg {
3463 mcp.RefreshResources(context.Background(), name)
3464 return nil
3465 }
3466}
3467
3468func (m *UI) copyChatHighlight() tea.Cmd {
3469 text := m.chat.HighlightContent()
3470 return common.CopyToClipboardWithCallback(
3471 text,
3472 "Selected text copied to clipboard",
3473 func() tea.Msg {
3474 m.chat.ClearMouse()
3475 return nil
3476 },
3477 )
3478}
3479
3480func (m *UI) enableDockerMCP() tea.Msg {
3481 store := m.com.Store()
3482 // Stage Docker MCP in memory first so startup and persistence can be atomic.
3483 mcpConfig, err := store.PrepareDockerMCPConfig()
3484 if err != nil {
3485 return util.ReportError(err)()
3486 }
3487
3488 ctx := context.Background()
3489 if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3490 // Roll back runtime and in-memory state when startup fails.
3491 disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3492 delete(store.Config().MCP, config.DockerMCPName)
3493 return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
3494 }
3495
3496 if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
3497 // Roll back runtime and in-memory state if persistence fails.
3498 disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3499 delete(store.Config().MCP, config.DockerMCPName)
3500 return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
3501 }
3502
3503 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3504}
3505
3506func (m *UI) disableDockerMCP() tea.Msg {
3507 store := m.com.Store()
3508 // Close the Docker MCP client.
3509 if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3510 return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3511 }
3512
3513 // Remove from config and persist.
3514 if err := store.DisableDockerMCP(); err != nil {
3515 return util.ReportError(err)()
3516 }
3517
3518 return util.NewInfoMsg("Docker MCP disabled successfully")
3519}
3520
3521// renderLogo renders the Crush logo with the given styles and dimensions.
3522func renderLogo(t *styles.Styles, compact bool, width int) string {
3523 return logo.Render(t, version.Version, compact, logo.Opts{
3524 FieldColor: t.LogoFieldColor,
3525 TitleColorA: t.LogoTitleColorA,
3526 TitleColorB: t.LogoTitleColorB,
3527 CharmColor: t.LogoCharmColor,
3528 VersionColor: t.LogoVersionColor,
3529 Width: width,
3530 })
3531}