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