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/commands"
32 "github.com/charmbracelet/crush/internal/config"
33 "github.com/charmbracelet/crush/internal/fsext"
34 "github.com/charmbracelet/crush/internal/history"
35 "github.com/charmbracelet/crush/internal/home"
36 "github.com/charmbracelet/crush/internal/message"
37 "github.com/charmbracelet/crush/internal/permission"
38 "github.com/charmbracelet/crush/internal/pubsub"
39 "github.com/charmbracelet/crush/internal/session"
40 "github.com/charmbracelet/crush/internal/ui/anim"
41 "github.com/charmbracelet/crush/internal/ui/attachments"
42 "github.com/charmbracelet/crush/internal/ui/chat"
43 "github.com/charmbracelet/crush/internal/ui/common"
44 "github.com/charmbracelet/crush/internal/ui/completions"
45 "github.com/charmbracelet/crush/internal/ui/dialog"
46 fimage "github.com/charmbracelet/crush/internal/ui/image"
47 "github.com/charmbracelet/crush/internal/ui/logo"
48 "github.com/charmbracelet/crush/internal/ui/notification"
49 "github.com/charmbracelet/crush/internal/ui/styles"
50 "github.com/charmbracelet/crush/internal/ui/util"
51 "github.com/charmbracelet/crush/internal/version"
52 "github.com/charmbracelet/crush/internal/workspace"
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]workspace.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]workspace.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, _ := com.Workspace.ProjectNeedsInitialization(); 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 sessions, err := m.com.Workspace.ListSessions(context.Background())
374 if err != nil || len(sessions) == 0 {
375 return nil
376 }
377 return m.loadSession(sessions[0].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.Workspace.AgentQueuedPrompts(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.Workspace.ListMessages(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[workspace.LSPEvent]:
603 m.lspStates = m.com.Workspace.LSPGetStates()
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(m.com.Workspace, msg.Payload.Name)
613 case mcp.EventToolsListChanged:
614 return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
615 case mcp.EventResourcesListChanged:
616 return m, handleMCPResourcesEvent(m.com.Workspace, 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 if msg.Type == util.InfoTypeError {
827 slog.Error("Error reported", "error", msg.Msg)
828 }
829 m.status.SetInfoMsg(msg)
830 ttl := msg.TTL
831 if ttl <= 0 {
832 ttl = DefaultStatusTTL
833 }
834 cmds = append(cmds, clearInfoMsgCmd(ttl))
835 case util.ClearStatusMsg:
836 m.status.ClearInfoMsg()
837 case completions.CompletionItemsLoadedMsg:
838 if m.completionsOpen {
839 m.completions.SetItems(msg.Files, msg.Resources)
840 }
841 case uv.KittyGraphicsEvent:
842 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
843 slog.Warn("Unexpected Kitty graphics response",
844 "response", string(msg.Payload),
845 "options", msg.Options)
846 }
847 default:
848 if m.dialog.HasDialogs() {
849 if cmd := m.handleDialogMsg(msg); cmd != nil {
850 cmds = append(cmds, cmd)
851 }
852 }
853 }
854
855 // This logic gets triggered on any message type, but should it?
856 switch m.focus {
857 case uiFocusMain:
858 case uiFocusEditor:
859 // Textarea placeholder logic
860 if m.isAgentBusy() {
861 m.textarea.Placeholder = m.workingPlaceholder
862 } else {
863 m.textarea.Placeholder = m.readyPlaceholder
864 }
865 if m.com.Workspace.PermissionSkipRequests() {
866 m.textarea.Placeholder = "Yolo mode!"
867 }
868 }
869
870 // at this point this can only handle [message.Attachment] message, and we
871 // should return all cmds anyway.
872 _ = m.attachments.Update(msg)
873 return m, tea.Batch(cmds...)
874}
875
876// setSessionMessages sets the messages for the current session in the chat
877func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
878 var cmds []tea.Cmd
879 // Build tool result map to link tool calls with their results
880 msgPtrs := make([]*message.Message, len(msgs))
881 for i := range msgs {
882 msgPtrs[i] = &msgs[i]
883 }
884 toolResultMap := chat.BuildToolResultMap(msgPtrs)
885 if len(msgPtrs) > 0 {
886 m.lastUserMessageTime = msgPtrs[0].CreatedAt
887 }
888
889 // Add messages to chat with linked tool results
890 items := make([]chat.MessageItem, 0, len(msgs)*2)
891 for _, msg := range msgPtrs {
892 switch msg.Role {
893 case message.User:
894 m.lastUserMessageTime = msg.CreatedAt
895 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
896 case message.Assistant:
897 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
898 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
899 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
900 items = append(items, infoItem)
901 }
902 default:
903 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
904 }
905 }
906
907 // Load nested tool calls for agent/agentic_fetch tools.
908 m.loadNestedToolCalls(items)
909
910 // If the user switches between sessions while the agent is working we want
911 // to make sure the animations are shown.
912 for _, item := range items {
913 if animatable, ok := item.(chat.Animatable); ok {
914 if cmd := animatable.StartAnimation(); cmd != nil {
915 cmds = append(cmds, cmd)
916 }
917 }
918 }
919
920 m.chat.SetMessages(items...)
921 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
922 cmds = append(cmds, cmd)
923 }
924 m.chat.SelectLast()
925 return tea.Sequence(cmds...)
926}
927
928// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
929func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
930 for _, item := range items {
931 nestedContainer, ok := item.(chat.NestedToolContainer)
932 if !ok {
933 continue
934 }
935 toolItem, ok := item.(chat.ToolMessageItem)
936 if !ok {
937 continue
938 }
939
940 tc := toolItem.ToolCall()
941 messageID := toolItem.MessageID()
942
943 // Get the agent tool session ID.
944 agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
945
946 // Fetch nested messages.
947 nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
948 if err != nil || len(nestedMsgs) == 0 {
949 continue
950 }
951
952 // Build tool result map for nested messages.
953 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
954 for i := range nestedMsgs {
955 nestedMsgPtrs[i] = &nestedMsgs[i]
956 }
957 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
958
959 // Extract nested tool items.
960 var nestedTools []chat.ToolMessageItem
961 for _, nestedMsg := range nestedMsgPtrs {
962 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
963 for _, nestedItem := range nestedItems {
964 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
965 // Mark nested tools as simple (compact) rendering.
966 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
967 simplifiable.SetCompact(true)
968 }
969 nestedTools = append(nestedTools, nestedToolItem)
970 }
971 }
972 }
973
974 // Recursively load nested tool calls for any agent tools within.
975 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
976 for i, nt := range nestedTools {
977 nestedMessageItems[i] = nt
978 }
979 m.loadNestedToolCalls(nestedMessageItems)
980
981 // Set nested tools on the parent.
982 nestedContainer.SetNestedTools(nestedTools)
983 }
984}
985
986// appendSessionMessage appends a new message to the current session in the chat
987// if the message is a tool result it will update the corresponding tool call message
988func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
989 var cmds []tea.Cmd
990
991 existing := m.chat.MessageItem(msg.ID)
992 if existing != nil {
993 // message already exists, skip
994 return nil
995 }
996
997 switch msg.Role {
998 case message.User:
999 m.lastUserMessageTime = msg.CreatedAt
1000 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1001 for _, item := range items {
1002 if animatable, ok := item.(chat.Animatable); ok {
1003 if cmd := animatable.StartAnimation(); cmd != nil {
1004 cmds = append(cmds, cmd)
1005 }
1006 }
1007 }
1008 m.chat.AppendMessages(items...)
1009 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1010 cmds = append(cmds, cmd)
1011 }
1012 case message.Assistant:
1013 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1014 for _, item := range items {
1015 if animatable, ok := item.(chat.Animatable); ok {
1016 if cmd := animatable.StartAnimation(); cmd != nil {
1017 cmds = append(cmds, cmd)
1018 }
1019 }
1020 }
1021 m.chat.AppendMessages(items...)
1022 if m.chat.Follow() {
1023 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1024 cmds = append(cmds, cmd)
1025 }
1026 }
1027 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1028 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1029 m.chat.AppendMessages(infoItem)
1030 if m.chat.Follow() {
1031 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1032 cmds = append(cmds, cmd)
1033 }
1034 }
1035 }
1036 case message.Tool:
1037 for _, tr := range msg.ToolResults() {
1038 toolItem := m.chat.MessageItem(tr.ToolCallID)
1039 if toolItem == nil {
1040 // we should have an item!
1041 continue
1042 }
1043 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1044 toolMsgItem.SetResult(&tr)
1045 if m.chat.Follow() {
1046 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1047 cmds = append(cmds, cmd)
1048 }
1049 }
1050 }
1051 }
1052 }
1053 return tea.Sequence(cmds...)
1054}
1055
1056func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1057 switch {
1058 case m.state != uiChat:
1059 return nil
1060 case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1061 return nil
1062 case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1063 m.focus = uiFocusEditor
1064 cmd = m.textarea.Focus()
1065 m.chat.Blur()
1066 case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1067 m.focus = uiFocusMain
1068 m.textarea.Blur()
1069 m.chat.Focus()
1070 }
1071 return cmd
1072}
1073
1074// updateSessionMessage updates an existing message in the current session in the chat
1075// when an assistant message is updated it may include updated tool calls as well
1076// that is why we need to handle creating/updating each tool call message too
1077func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1078 var cmds []tea.Cmd
1079 existingItem := m.chat.MessageItem(msg.ID)
1080
1081 if existingItem != nil {
1082 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1083 assistantItem.SetMessage(&msg)
1084 }
1085 }
1086
1087 shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1088 // if the message of the assistant does not have any response just tool calls we need to remove it
1089 if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1090 m.chat.RemoveMessage(msg.ID)
1091 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1092 m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1093 }
1094 }
1095
1096 if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1097 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1098 newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1099 m.chat.AppendMessages(newInfoItem)
1100 }
1101 }
1102
1103 var items []chat.MessageItem
1104 for _, tc := range msg.ToolCalls() {
1105 existingToolItem := m.chat.MessageItem(tc.ID)
1106 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1107 existingToolCall := toolItem.ToolCall()
1108 // only update if finished state changed or input changed
1109 // to avoid clearing the cache
1110 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1111 toolItem.SetToolCall(tc)
1112 }
1113 }
1114 if existingToolItem == nil {
1115 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1116 }
1117 }
1118
1119 for _, item := range items {
1120 if animatable, ok := item.(chat.Animatable); ok {
1121 if cmd := animatable.StartAnimation(); cmd != nil {
1122 cmds = append(cmds, cmd)
1123 }
1124 }
1125 }
1126
1127 m.chat.AppendMessages(items...)
1128 if m.chat.Follow() {
1129 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1130 cmds = append(cmds, cmd)
1131 }
1132 m.chat.SelectLast()
1133 }
1134
1135 return tea.Sequence(cmds...)
1136}
1137
1138// handleChildSessionMessage handles messages from child sessions (agent tools).
1139func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1140 var cmds []tea.Cmd
1141
1142 // Only process messages with tool calls or results.
1143 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1144 return nil
1145 }
1146
1147 // Check if this is an agent tool session and parse it.
1148 childSessionID := event.Payload.SessionID
1149 _, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
1150 if !ok {
1151 return nil
1152 }
1153
1154 // Find the parent agent tool item.
1155 var agentItem chat.NestedToolContainer
1156 for i := 0; i < m.chat.Len(); i++ {
1157 item := m.chat.MessageItem(toolCallID)
1158 if item == nil {
1159 continue
1160 }
1161 if agent, ok := item.(chat.NestedToolContainer); ok {
1162 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1163 if toolMessageItem.ToolCall().ID == toolCallID {
1164 // Verify this agent belongs to the correct parent message.
1165 // We can't directly check parentMessageID on the item, so we trust the session parsing.
1166 agentItem = agent
1167 break
1168 }
1169 }
1170 }
1171 }
1172
1173 if agentItem == nil {
1174 return nil
1175 }
1176
1177 // Get existing nested tools.
1178 nestedTools := agentItem.NestedTools()
1179
1180 // Update or create nested tool calls.
1181 for _, tc := range event.Payload.ToolCalls() {
1182 found := false
1183 for _, existingTool := range nestedTools {
1184 if existingTool.ToolCall().ID == tc.ID {
1185 existingTool.SetToolCall(tc)
1186 found = true
1187 break
1188 }
1189 }
1190 if !found {
1191 // Create a new nested tool item.
1192 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1193 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1194 simplifiable.SetCompact(true)
1195 }
1196 if animatable, ok := nestedItem.(chat.Animatable); ok {
1197 if cmd := animatable.StartAnimation(); cmd != nil {
1198 cmds = append(cmds, cmd)
1199 }
1200 }
1201 nestedTools = append(nestedTools, nestedItem)
1202 }
1203 }
1204
1205 // Update nested tool results.
1206 for _, tr := range event.Payload.ToolResults() {
1207 for _, nestedTool := range nestedTools {
1208 if nestedTool.ToolCall().ID == tr.ToolCallID {
1209 nestedTool.SetResult(&tr)
1210 break
1211 }
1212 }
1213 }
1214
1215 // Update the agent item with the new nested tools.
1216 agentItem.SetNestedTools(nestedTools)
1217
1218 // Update the chat so it updates the index map for animations to work as expected
1219 m.chat.UpdateNestedToolIDs(toolCallID)
1220
1221 if m.chat.Follow() {
1222 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1223 cmds = append(cmds, cmd)
1224 }
1225 m.chat.SelectLast()
1226 }
1227
1228 return tea.Sequence(cmds...)
1229}
1230
1231func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1232 var cmds []tea.Cmd
1233 action := m.dialog.Update(msg)
1234 if action == nil {
1235 return tea.Batch(cmds...)
1236 }
1237
1238 isOnboarding := m.state == uiOnboarding
1239
1240 switch msg := action.(type) {
1241 // Generic dialog messages
1242 case dialog.ActionClose:
1243 if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1244 break
1245 }
1246
1247 if m.dialog.ContainsDialog(dialog.FilePickerID) {
1248 defer fimage.ResetCache()
1249 }
1250
1251 m.dialog.CloseFrontDialog()
1252
1253 if isOnboarding {
1254 if cmd := m.openModelsDialog(); cmd != nil {
1255 cmds = append(cmds, cmd)
1256 }
1257 }
1258
1259 if m.focus == uiFocusEditor {
1260 cmds = append(cmds, m.textarea.Focus())
1261 }
1262 case dialog.ActionCmd:
1263 if msg.Cmd != nil {
1264 cmds = append(cmds, msg.Cmd)
1265 }
1266
1267 // Session dialog messages.
1268 case dialog.ActionSelectSession:
1269 m.dialog.CloseDialog(dialog.SessionsID)
1270 cmds = append(cmds, m.loadSession(msg.Session.ID))
1271
1272 // Open dialog message.
1273 case dialog.ActionOpenDialog:
1274 m.dialog.CloseDialog(dialog.CommandsID)
1275 if cmd := m.openDialog(msg.DialogID); cmd != nil {
1276 cmds = append(cmds, cmd)
1277 }
1278
1279 // Command dialog messages.
1280 case dialog.ActionToggleYoloMode:
1281 yolo := !m.com.Workspace.PermissionSkipRequests()
1282 m.com.Workspace.PermissionSetSkipRequests(yolo)
1283 m.setEditorPrompt(yolo)
1284 m.dialog.CloseDialog(dialog.CommandsID)
1285 case dialog.ActionToggleNotifications:
1286 cfg := m.com.Config()
1287 if cfg != nil && cfg.Options != nil {
1288 disabled := !cfg.Options.DisableNotifications
1289 cfg.Options.DisableNotifications = disabled
1290 if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1291 cmds = append(cmds, util.ReportError(err))
1292 } else {
1293 status := "enabled"
1294 if disabled {
1295 status = "disabled"
1296 }
1297 cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
1298 }
1299 }
1300 m.dialog.CloseDialog(dialog.CommandsID)
1301 case dialog.ActionNewSession:
1302 if m.isAgentBusy() {
1303 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1304 break
1305 }
1306 if cmd := m.newSession(); cmd != nil {
1307 cmds = append(cmds, cmd)
1308 }
1309 m.dialog.CloseDialog(dialog.CommandsID)
1310 case dialog.ActionSummarize:
1311 if m.isAgentBusy() {
1312 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1313 break
1314 }
1315 cmds = append(cmds, func() tea.Msg {
1316 err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1317 if err != nil {
1318 return util.ReportError(err)()
1319 }
1320 return nil
1321 })
1322 m.dialog.CloseDialog(dialog.CommandsID)
1323 case dialog.ActionToggleHelp:
1324 m.status.ToggleHelp()
1325 m.dialog.CloseDialog(dialog.CommandsID)
1326 case dialog.ActionExternalEditor:
1327 if m.isAgentBusy() {
1328 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1329 break
1330 }
1331 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1332 m.dialog.CloseDialog(dialog.CommandsID)
1333 case dialog.ActionToggleCompactMode:
1334 cmds = append(cmds, m.toggleCompactMode())
1335 m.dialog.CloseDialog(dialog.CommandsID)
1336 case dialog.ActionTogglePills:
1337 if cmd := m.togglePillsExpanded(); cmd != nil {
1338 cmds = append(cmds, cmd)
1339 }
1340 m.dialog.CloseDialog(dialog.CommandsID)
1341 case dialog.ActionToggleThinking:
1342 cmds = append(cmds, func() tea.Msg {
1343 cfg := m.com.Config()
1344 if cfg == nil {
1345 return util.ReportError(errors.New("configuration not found"))()
1346 }
1347
1348 agentCfg, ok := cfg.Agents[config.AgentCoder]
1349 if !ok {
1350 return util.ReportError(errors.New("agent configuration not found"))()
1351 }
1352
1353 currentModel := cfg.Models[agentCfg.Model]
1354 currentModel.Think = !currentModel.Think
1355 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1356 return util.ReportError(err)()
1357 }
1358 m.com.Workspace.UpdateAgentModel(context.TODO())
1359 status := "disabled"
1360 if currentModel.Think {
1361 status = "enabled"
1362 }
1363 return util.NewInfoMsg("Thinking mode " + status)
1364 })
1365 m.dialog.CloseDialog(dialog.CommandsID)
1366 case dialog.ActionToggleTransparentBackground:
1367 cmds = append(cmds, func() tea.Msg {
1368 cfg := m.com.Config()
1369 if cfg == nil {
1370 return util.ReportError(errors.New("configuration not found"))()
1371 }
1372
1373 isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
1374 newValue := !isTransparent
1375 if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.tui.transparent", newValue); err != nil {
1376 return util.ReportError(err)()
1377 }
1378 m.isTransparent = newValue
1379
1380 status := "disabled"
1381 if newValue {
1382 status = "enabled"
1383 }
1384 return util.NewInfoMsg("Transparent background " + status)
1385 })
1386 m.dialog.CloseDialog(dialog.CommandsID)
1387 case dialog.ActionQuit:
1388 cmds = append(cmds, tea.Quit)
1389 case dialog.ActionEnableDockerMCP:
1390 m.dialog.CloseDialog(dialog.CommandsID)
1391 cmds = append(cmds, m.enableDockerMCP)
1392 case dialog.ActionDisableDockerMCP:
1393 m.dialog.CloseDialog(dialog.CommandsID)
1394 cmds = append(cmds, m.disableDockerMCP)
1395 case dialog.ActionInitializeProject:
1396 if m.isAgentBusy() {
1397 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1398 break
1399 }
1400 cmds = append(cmds, m.initializeProject())
1401 m.dialog.CloseDialog(dialog.CommandsID)
1402
1403 case dialog.ActionSelectModel:
1404 if m.isAgentBusy() {
1405 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1406 break
1407 }
1408
1409 cfg := m.com.Config()
1410 if cfg == nil {
1411 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1412 break
1413 }
1414
1415 var (
1416 providerID = msg.Model.Provider
1417 isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1418 isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1419 )
1420
1421 // Attempt to import GitHub Copilot tokens from VSCode if available.
1422 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1423 m.com.Workspace.ImportCopilot()
1424 }
1425
1426 if !isConfigured() || msg.ReAuthenticate {
1427 m.dialog.CloseDialog(dialog.ModelsID)
1428 if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1429 cmds = append(cmds, cmd)
1430 }
1431 break
1432 }
1433
1434 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1435 cmds = append(cmds, util.ReportError(err))
1436 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1437 // Ensure small model is set is unset.
1438 smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1439 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1440 cmds = append(cmds, util.ReportError(err))
1441 }
1442 }
1443
1444 cmds = append(cmds, func() tea.Msg {
1445 if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1446 return util.ReportError(err)
1447 }
1448
1449 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1450
1451 return util.NewInfoMsg(modelMsg)
1452 })
1453
1454 m.dialog.CloseDialog(dialog.APIKeyInputID)
1455 m.dialog.CloseDialog(dialog.OAuthID)
1456 m.dialog.CloseDialog(dialog.ModelsID)
1457
1458 if isOnboarding {
1459 m.setState(uiLanding, uiFocusEditor)
1460 m.com.Config().SetupAgents()
1461 if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1462 cmds = append(cmds, util.ReportError(err))
1463 }
1464 }
1465 case dialog.ActionSelectReasoningEffort:
1466 if m.isAgentBusy() {
1467 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1468 break
1469 }
1470
1471 cfg := m.com.Config()
1472 if cfg == nil {
1473 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1474 break
1475 }
1476
1477 agentCfg, ok := cfg.Agents[config.AgentCoder]
1478 if !ok {
1479 cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1480 break
1481 }
1482
1483 currentModel := cfg.Models[agentCfg.Model]
1484 currentModel.ReasoningEffort = msg.Effort
1485 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1486 cmds = append(cmds, util.ReportError(err))
1487 break
1488 }
1489
1490 cmds = append(cmds, func() tea.Msg {
1491 m.com.Workspace.UpdateAgentModel(context.TODO())
1492 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1493 })
1494 m.dialog.CloseDialog(dialog.ReasoningID)
1495 case dialog.ActionPermissionResponse:
1496 m.dialog.CloseDialog(dialog.PermissionsID)
1497 switch msg.Action {
1498 case dialog.PermissionAllow:
1499 m.com.Workspace.PermissionGrant(msg.Permission)
1500 case dialog.PermissionAllowForSession:
1501 m.com.Workspace.PermissionGrantPersistent(msg.Permission)
1502 case dialog.PermissionDeny:
1503 m.com.Workspace.PermissionDeny(msg.Permission)
1504 }
1505
1506 case dialog.ActionFilePickerSelected:
1507 cmds = append(cmds, tea.Sequence(
1508 msg.Cmd(),
1509 func() tea.Msg {
1510 m.dialog.CloseDialog(dialog.FilePickerID)
1511 return nil
1512 },
1513 func() tea.Msg {
1514 fimage.ResetCache()
1515 return nil
1516 },
1517 ))
1518
1519 case dialog.ActionRunCustomCommand:
1520 if len(msg.Arguments) > 0 && msg.Args == nil {
1521 m.dialog.CloseFrontDialog()
1522 argsDialog := dialog.NewArguments(
1523 m.com,
1524 "Custom Command Arguments",
1525 "",
1526 msg.Arguments,
1527 msg, // Pass the action as the result
1528 )
1529 m.dialog.OpenDialog(argsDialog)
1530 break
1531 }
1532 content := msg.Content
1533 if msg.Args != nil {
1534 content = substituteArgs(content, msg.Args)
1535 }
1536 cmds = append(cmds, m.sendMessage(content))
1537 m.dialog.CloseFrontDialog()
1538 case dialog.ActionRunMCPPrompt:
1539 if len(msg.Arguments) > 0 && msg.Args == nil {
1540 m.dialog.CloseFrontDialog()
1541 title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1542 argsDialog := dialog.NewArguments(
1543 m.com,
1544 title,
1545 msg.Description,
1546 msg.Arguments,
1547 msg, // Pass the action as the result
1548 )
1549 m.dialog.OpenDialog(argsDialog)
1550 break
1551 }
1552 cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1553 default:
1554 cmds = append(cmds, util.CmdHandler(msg))
1555 }
1556
1557 return tea.Batch(cmds...)
1558}
1559
1560// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1561func substituteArgs(content string, args map[string]string) string {
1562 for name, value := range args {
1563 placeholder := "$" + name
1564 content = strings.ReplaceAll(content, placeholder, value)
1565 }
1566 return content
1567}
1568
1569func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1570 var (
1571 dlg dialog.Dialog
1572 cmd tea.Cmd
1573
1574 isOnboarding = m.state == uiOnboarding
1575 )
1576
1577 switch provider.ID {
1578 case "hyper":
1579 dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1580 case catwalk.InferenceProviderCopilot:
1581 dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1582 default:
1583 dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1584 }
1585
1586 if m.dialog.ContainsDialog(dlg.ID()) {
1587 m.dialog.BringToFront(dlg.ID())
1588 return nil
1589 }
1590
1591 m.dialog.OpenDialog(dlg)
1592 return cmd
1593}
1594
1595func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1596 var cmds []tea.Cmd
1597
1598 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1599 switch {
1600 case key.Matches(msg, m.keyMap.Help):
1601 m.status.ToggleHelp()
1602 m.updateLayoutAndSize()
1603 return true
1604 case key.Matches(msg, m.keyMap.Commands):
1605 if cmd := m.openCommandsDialog(); cmd != nil {
1606 cmds = append(cmds, cmd)
1607 }
1608 return true
1609 case key.Matches(msg, m.keyMap.Models):
1610 if cmd := m.openModelsDialog(); cmd != nil {
1611 cmds = append(cmds, cmd)
1612 }
1613 return true
1614 case key.Matches(msg, m.keyMap.Sessions):
1615 if cmd := m.openSessionsDialog(); cmd != nil {
1616 cmds = append(cmds, cmd)
1617 }
1618 return true
1619 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1620 m.detailsOpen = !m.detailsOpen
1621 m.updateLayoutAndSize()
1622 return true
1623 case key.Matches(msg, m.keyMap.Chat.TogglePills):
1624 if m.state == uiChat && m.hasSession() {
1625 if cmd := m.togglePillsExpanded(); cmd != nil {
1626 cmds = append(cmds, cmd)
1627 }
1628 return true
1629 }
1630 case key.Matches(msg, m.keyMap.Chat.PillLeft):
1631 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1632 if cmd := m.switchPillSection(-1); cmd != nil {
1633 cmds = append(cmds, cmd)
1634 }
1635 return true
1636 }
1637 case key.Matches(msg, m.keyMap.Chat.PillRight):
1638 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1639 if cmd := m.switchPillSection(1); cmd != nil {
1640 cmds = append(cmds, cmd)
1641 }
1642 return true
1643 }
1644 case key.Matches(msg, m.keyMap.Suspend):
1645 if m.isAgentBusy() {
1646 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1647 return true
1648 }
1649 cmds = append(cmds, tea.Suspend)
1650 return true
1651 }
1652 return false
1653 }
1654
1655 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1656 // Always handle quit keys first
1657 if cmd := m.openQuitDialog(); cmd != nil {
1658 cmds = append(cmds, cmd)
1659 }
1660
1661 return tea.Batch(cmds...)
1662 }
1663
1664 // Route all messages to dialog if one is open.
1665 if m.dialog.HasDialogs() {
1666 return m.handleDialogMsg(msg)
1667 }
1668
1669 // Handle cancel key when agent is busy.
1670 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1671 if m.isAgentBusy() {
1672 if cmd := m.cancelAgent(); cmd != nil {
1673 cmds = append(cmds, cmd)
1674 }
1675 return tea.Batch(cmds...)
1676 }
1677 }
1678
1679 switch m.state {
1680 case uiOnboarding:
1681 return tea.Batch(cmds...)
1682 case uiInitialize:
1683 cmds = append(cmds, m.updateInitializeView(msg)...)
1684 return tea.Batch(cmds...)
1685 case uiChat, uiLanding:
1686 switch m.focus {
1687 case uiFocusEditor:
1688 // Handle completions if open.
1689 if m.completionsOpen {
1690 if msg, ok := m.completions.Update(msg); ok {
1691 switch msg := msg.(type) {
1692 case completions.SelectionMsg[completions.FileCompletionValue]:
1693 cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1694 if !msg.KeepOpen {
1695 m.closeCompletions()
1696 }
1697 case completions.SelectionMsg[completions.ResourceCompletionValue]:
1698 cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1699 if !msg.KeepOpen {
1700 m.closeCompletions()
1701 }
1702 case completions.ClosedMsg:
1703 m.completionsOpen = false
1704 }
1705 return tea.Batch(cmds...)
1706 }
1707 }
1708
1709 if ok := m.attachments.Update(msg); ok {
1710 return tea.Batch(cmds...)
1711 }
1712
1713 switch {
1714 case key.Matches(msg, m.keyMap.Editor.AddImage):
1715 if cmd := m.openFilesDialog(); cmd != nil {
1716 cmds = append(cmds, cmd)
1717 }
1718
1719 case key.Matches(msg, m.keyMap.Editor.PasteImage):
1720 cmds = append(cmds, m.pasteImageFromClipboard)
1721
1722 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1723 value := m.textarea.Value()
1724 if before, ok := strings.CutSuffix(value, "\\"); ok {
1725 // If the last character is a backslash, remove it and add a newline.
1726 m.textarea.SetValue(before)
1727 break
1728 }
1729
1730 // Otherwise, send the message
1731 m.textarea.Reset()
1732
1733 value = strings.TrimSpace(value)
1734 if value == "exit" || value == "quit" {
1735 return m.openQuitDialog()
1736 }
1737
1738 attachments := m.attachments.List()
1739 m.attachments.Reset()
1740 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1741 return nil
1742 }
1743
1744 m.randomizePlaceholders()
1745 m.historyReset()
1746
1747 return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1748 case key.Matches(msg, m.keyMap.Chat.NewSession):
1749 if !m.hasSession() {
1750 break
1751 }
1752 if m.isAgentBusy() {
1753 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1754 break
1755 }
1756 if cmd := m.newSession(); cmd != nil {
1757 cmds = append(cmds, cmd)
1758 }
1759 case key.Matches(msg, m.keyMap.Tab):
1760 if m.state != uiLanding {
1761 m.setState(m.state, uiFocusMain)
1762 m.textarea.Blur()
1763 m.chat.Focus()
1764 m.chat.SetSelected(m.chat.Len() - 1)
1765 }
1766 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1767 if m.isAgentBusy() {
1768 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1769 break
1770 }
1771 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1772 case key.Matches(msg, m.keyMap.Editor.Newline):
1773 m.textarea.InsertRune('\n')
1774 m.closeCompletions()
1775 ta, cmd := m.textarea.Update(msg)
1776 m.textarea = ta
1777 cmds = append(cmds, cmd)
1778 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1779 cmd := m.handleHistoryUp(msg)
1780 if cmd != nil {
1781 cmds = append(cmds, cmd)
1782 }
1783 case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1784 cmd := m.handleHistoryDown(msg)
1785 if cmd != nil {
1786 cmds = append(cmds, cmd)
1787 }
1788 case key.Matches(msg, m.keyMap.Editor.Escape):
1789 cmd := m.handleHistoryEscape(msg)
1790 if cmd != nil {
1791 cmds = append(cmds, cmd)
1792 }
1793 case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1794 if cmd := m.openCommandsDialog(); cmd != nil {
1795 cmds = append(cmds, cmd)
1796 }
1797 default:
1798 if handleGlobalKeys(msg) {
1799 // Handle global keys first before passing to textarea.
1800 break
1801 }
1802
1803 // Check for @ trigger before passing to textarea.
1804 curValue := m.textarea.Value()
1805 curIdx := len(curValue)
1806
1807 // Trigger completions on @.
1808 if msg.String() == "@" && !m.completionsOpen {
1809 // Only show if beginning of prompt or after whitespace.
1810 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1811 m.completionsOpen = true
1812 m.completionsQuery = ""
1813 m.completionsStartIndex = curIdx
1814 m.completionsPositionStart = m.completionsPosition()
1815 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1816 cmds = append(cmds, m.completions.Open(depth, limit))
1817 }
1818 }
1819
1820 // remove the details if they are open when user starts typing
1821 if m.detailsOpen {
1822 m.detailsOpen = false
1823 m.updateLayoutAndSize()
1824 }
1825
1826 ta, cmd := m.textarea.Update(msg)
1827 m.textarea = ta
1828 cmds = append(cmds, cmd)
1829
1830 // Any text modification becomes the current draft.
1831 m.updateHistoryDraft(curValue)
1832
1833 // After updating textarea, check if we need to filter completions.
1834 // Skip filtering on the initial @ keystroke since items are loading async.
1835 if m.completionsOpen && msg.String() != "@" {
1836 newValue := m.textarea.Value()
1837 newIdx := len(newValue)
1838
1839 // Close completions if cursor moved before start.
1840 if newIdx <= m.completionsStartIndex {
1841 m.closeCompletions()
1842 } else if msg.String() == "space" {
1843 // Close on space.
1844 m.closeCompletions()
1845 } else {
1846 // Extract current word and filter.
1847 word := m.textareaWord()
1848 if strings.HasPrefix(word, "@") {
1849 m.completionsQuery = word[1:]
1850 m.completions.Filter(m.completionsQuery)
1851 } else if m.completionsOpen {
1852 m.closeCompletions()
1853 }
1854 }
1855 }
1856 }
1857 case uiFocusMain:
1858 switch {
1859 case key.Matches(msg, m.keyMap.Tab):
1860 m.focus = uiFocusEditor
1861 cmds = append(cmds, m.textarea.Focus())
1862 m.chat.Blur()
1863 case key.Matches(msg, m.keyMap.Chat.NewSession):
1864 if !m.hasSession() {
1865 break
1866 }
1867 if m.isAgentBusy() {
1868 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1869 break
1870 }
1871 m.focus = uiFocusEditor
1872 if cmd := m.newSession(); cmd != nil {
1873 cmds = append(cmds, cmd)
1874 }
1875 case key.Matches(msg, m.keyMap.Chat.Expand):
1876 m.chat.ToggleExpandedSelectedItem()
1877 case key.Matches(msg, m.keyMap.Chat.Up):
1878 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1879 cmds = append(cmds, cmd)
1880 }
1881 if !m.chat.SelectedItemInView() {
1882 m.chat.SelectPrev()
1883 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1884 cmds = append(cmds, cmd)
1885 }
1886 }
1887 case key.Matches(msg, m.keyMap.Chat.Down):
1888 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1889 cmds = append(cmds, cmd)
1890 }
1891 if !m.chat.SelectedItemInView() {
1892 m.chat.SelectNext()
1893 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1894 cmds = append(cmds, cmd)
1895 }
1896 }
1897 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1898 m.chat.SelectPrev()
1899 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1900 cmds = append(cmds, cmd)
1901 }
1902 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1903 m.chat.SelectNext()
1904 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1905 cmds = append(cmds, cmd)
1906 }
1907 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1908 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1909 cmds = append(cmds, cmd)
1910 }
1911 m.chat.SelectFirstInView()
1912 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1913 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1914 cmds = append(cmds, cmd)
1915 }
1916 m.chat.SelectLastInView()
1917 case key.Matches(msg, m.keyMap.Chat.PageUp):
1918 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1919 cmds = append(cmds, cmd)
1920 }
1921 m.chat.SelectFirstInView()
1922 case key.Matches(msg, m.keyMap.Chat.PageDown):
1923 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1924 cmds = append(cmds, cmd)
1925 }
1926 m.chat.SelectLastInView()
1927 case key.Matches(msg, m.keyMap.Chat.Home):
1928 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1929 cmds = append(cmds, cmd)
1930 }
1931 m.chat.SelectFirst()
1932 case key.Matches(msg, m.keyMap.Chat.End):
1933 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1934 cmds = append(cmds, cmd)
1935 }
1936 m.chat.SelectLast()
1937 default:
1938 if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1939 cmds = append(cmds, cmd)
1940 } else {
1941 handleGlobalKeys(msg)
1942 }
1943 }
1944 default:
1945 handleGlobalKeys(msg)
1946 }
1947 default:
1948 handleGlobalKeys(msg)
1949 }
1950
1951 return tea.Sequence(cmds...)
1952}
1953
1954// drawHeader draws the header section of the UI.
1955func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1956 m.header.drawHeader(
1957 scr,
1958 area,
1959 m.session,
1960 m.isCompact,
1961 m.detailsOpen,
1962 area.Dx(),
1963 )
1964}
1965
1966// Draw implements [uv.Drawable] and draws the UI model.
1967func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1968 layout := m.generateLayout(area.Dx(), area.Dy())
1969
1970 if m.layout != layout {
1971 m.layout = layout
1972 m.updateSize()
1973 }
1974
1975 // Clear the screen first
1976 screen.Clear(scr)
1977
1978 switch m.state {
1979 case uiOnboarding:
1980 m.drawHeader(scr, layout.header)
1981
1982 // NOTE: Onboarding flow will be rendered as dialogs below, but
1983 // positioned at the bottom left of the screen.
1984
1985 case uiInitialize:
1986 m.drawHeader(scr, layout.header)
1987
1988 main := uv.NewStyledString(m.initializeView())
1989 main.Draw(scr, layout.main)
1990
1991 case uiLanding:
1992 m.drawHeader(scr, layout.header)
1993 main := uv.NewStyledString(m.landingView())
1994 main.Draw(scr, layout.main)
1995
1996 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1997 editor.Draw(scr, layout.editor)
1998
1999 case uiChat:
2000 if m.isCompact {
2001 m.drawHeader(scr, layout.header)
2002 } else {
2003 m.drawSidebar(scr, layout.sidebar)
2004 }
2005
2006 m.chat.Draw(scr, layout.main)
2007 if layout.pills.Dy() > 0 && m.pillsView != "" {
2008 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2009 }
2010
2011 editorWidth := scr.Bounds().Dx()
2012 if !m.isCompact {
2013 editorWidth -= layout.sidebar.Dx()
2014 }
2015 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2016 editor.Draw(scr, layout.editor)
2017
2018 // Draw details overlay in compact mode when open
2019 if m.isCompact && m.detailsOpen {
2020 m.drawSessionDetails(scr, layout.sessionDetails)
2021 }
2022 }
2023
2024 isOnboarding := m.state == uiOnboarding
2025
2026 // Add status and help layer
2027 m.status.SetHideHelp(isOnboarding)
2028 m.status.Draw(scr, layout.status)
2029
2030 // Draw completions popup if open
2031 if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2032 w, h := m.completions.Size()
2033 x := m.completionsPositionStart.X
2034 y := m.completionsPositionStart.Y - h
2035
2036 screenW := area.Dx()
2037 if x+w > screenW {
2038 x = screenW - w
2039 }
2040 x = max(0, x)
2041 y = max(0, y+1) // Offset for attachments row
2042
2043 completionsView := uv.NewStyledString(m.completions.Render())
2044 completionsView.Draw(scr, image.Rectangle{
2045 Min: image.Pt(x, y),
2046 Max: image.Pt(x+w, y+h),
2047 })
2048 }
2049
2050 // Debugging rendering (visually see when the tui rerenders)
2051 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2052 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2053 debug := uv.NewStyledString(debugView.String())
2054 debug.Draw(scr, image.Rectangle{
2055 Min: image.Pt(4, 1),
2056 Max: image.Pt(8, 3),
2057 })
2058 }
2059
2060 // This needs to come last to overlay on top of everything. We always pass
2061 // the full screen bounds because the dialogs will position themselves
2062 // accordingly.
2063 if m.dialog.HasDialogs() {
2064 return m.dialog.Draw(scr, scr.Bounds())
2065 }
2066
2067 switch m.focus {
2068 case uiFocusEditor:
2069 if m.layout.editor.Dy() <= 0 {
2070 // Don't show cursor if editor is not visible
2071 return nil
2072 }
2073 if m.detailsOpen && m.isCompact {
2074 // Don't show cursor if details overlay is open
2075 return nil
2076 }
2077
2078 if m.textarea.Focused() {
2079 cur := m.textarea.Cursor()
2080 cur.X++ // Adjust for app margins
2081 cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2082 return cur
2083 }
2084 }
2085 return nil
2086}
2087
2088// View renders the UI model's view.
2089func (m *UI) View() tea.View {
2090 var v tea.View
2091 v.AltScreen = true
2092 if !m.isTransparent {
2093 v.BackgroundColor = m.com.Styles.Background
2094 }
2095 v.MouseMode = tea.MouseModeCellMotion
2096 v.ReportFocus = m.caps.ReportFocusEvents
2097 v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2098
2099 canvas := uv.NewScreenBuffer(m.width, m.height)
2100 v.Cursor = m.Draw(canvas, canvas.Bounds())
2101
2102 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2103 contentLines := strings.Split(content, "\n")
2104 for i, line := range contentLines {
2105 // Trim trailing spaces for concise rendering
2106 contentLines[i] = strings.TrimRight(line, " ")
2107 }
2108
2109 content = strings.Join(contentLines, "\n")
2110
2111 v.Content = content
2112 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2113 // HACK: use a random percentage to prevent ghostty from hiding it
2114 // after a timeout.
2115 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2116 }
2117
2118 return v
2119}
2120
2121// ShortHelp implements [help.KeyMap].
2122func (m *UI) ShortHelp() []key.Binding {
2123 var binds []key.Binding
2124 k := &m.keyMap
2125 tab := k.Tab
2126 commands := k.Commands
2127 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2128 commands.SetHelp("/ or ctrl+p", "commands")
2129 }
2130
2131 switch m.state {
2132 case uiInitialize:
2133 binds = append(binds, k.Quit)
2134 case uiChat:
2135 // Show cancel binding if agent is busy.
2136 if m.isAgentBusy() {
2137 cancelBinding := k.Chat.Cancel
2138 if m.isCanceling {
2139 cancelBinding.SetHelp("esc", "press again to cancel")
2140 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2141 cancelBinding.SetHelp("esc", "clear queue")
2142 }
2143 binds = append(binds, cancelBinding)
2144 }
2145
2146 if m.focus == uiFocusEditor {
2147 tab.SetHelp("tab", "focus chat")
2148 } else {
2149 tab.SetHelp("tab", "focus editor")
2150 }
2151
2152 binds = append(binds,
2153 tab,
2154 commands,
2155 k.Models,
2156 )
2157
2158 switch m.focus {
2159 case uiFocusEditor:
2160 binds = append(binds,
2161 k.Editor.Newline,
2162 )
2163 case uiFocusMain:
2164 binds = append(binds,
2165 k.Chat.UpDown,
2166 k.Chat.UpDownOneItem,
2167 k.Chat.PageUp,
2168 k.Chat.PageDown,
2169 k.Chat.Copy,
2170 )
2171 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2172 binds = append(binds, k.Chat.PillLeft)
2173 }
2174 }
2175 default:
2176 // TODO: other states
2177 // if m.session == nil {
2178 // no session selected
2179 binds = append(binds,
2180 commands,
2181 k.Models,
2182 k.Editor.Newline,
2183 )
2184 }
2185
2186 binds = append(binds,
2187 k.Quit,
2188 k.Help,
2189 )
2190
2191 return binds
2192}
2193
2194// FullHelp implements [help.KeyMap].
2195func (m *UI) FullHelp() [][]key.Binding {
2196 var binds [][]key.Binding
2197 k := &m.keyMap
2198 help := k.Help
2199 help.SetHelp("ctrl+g", "less")
2200 hasAttachments := len(m.attachments.List()) > 0
2201 hasSession := m.hasSession()
2202 commands := k.Commands
2203 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2204 commands.SetHelp("/ or ctrl+p", "commands")
2205 }
2206
2207 switch m.state {
2208 case uiInitialize:
2209 binds = append(binds,
2210 []key.Binding{
2211 k.Quit,
2212 })
2213 case uiChat:
2214 // Show cancel binding if agent is busy.
2215 if m.isAgentBusy() {
2216 cancelBinding := k.Chat.Cancel
2217 if m.isCanceling {
2218 cancelBinding.SetHelp("esc", "press again to cancel")
2219 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2220 cancelBinding.SetHelp("esc", "clear queue")
2221 }
2222 binds = append(binds, []key.Binding{cancelBinding})
2223 }
2224
2225 mainBinds := []key.Binding{}
2226 tab := k.Tab
2227 if m.focus == uiFocusEditor {
2228 tab.SetHelp("tab", "focus chat")
2229 } else {
2230 tab.SetHelp("tab", "focus editor")
2231 }
2232
2233 mainBinds = append(mainBinds,
2234 tab,
2235 commands,
2236 k.Models,
2237 k.Sessions,
2238 )
2239 if hasSession {
2240 mainBinds = append(mainBinds, k.Chat.NewSession)
2241 }
2242
2243 binds = append(binds, mainBinds)
2244
2245 switch m.focus {
2246 case uiFocusEditor:
2247 binds = append(binds,
2248 []key.Binding{
2249 k.Editor.Newline,
2250 k.Editor.AddImage,
2251 k.Editor.PasteImage,
2252 k.Editor.MentionFile,
2253 k.Editor.OpenEditor,
2254 },
2255 )
2256 if hasAttachments {
2257 binds = append(binds,
2258 []key.Binding{
2259 k.Editor.AttachmentDeleteMode,
2260 k.Editor.DeleteAllAttachments,
2261 k.Editor.Escape,
2262 },
2263 )
2264 }
2265 case uiFocusMain:
2266 binds = append(binds,
2267 []key.Binding{
2268 k.Chat.UpDown,
2269 k.Chat.UpDownOneItem,
2270 k.Chat.PageUp,
2271 k.Chat.PageDown,
2272 },
2273 []key.Binding{
2274 k.Chat.HalfPageUp,
2275 k.Chat.HalfPageDown,
2276 k.Chat.Home,
2277 k.Chat.End,
2278 },
2279 []key.Binding{
2280 k.Chat.Copy,
2281 k.Chat.ClearHighlight,
2282 },
2283 )
2284 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2285 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2286 }
2287 }
2288 default:
2289 if m.session == nil {
2290 // no session selected
2291 binds = append(binds,
2292 []key.Binding{
2293 commands,
2294 k.Models,
2295 k.Sessions,
2296 },
2297 []key.Binding{
2298 k.Editor.Newline,
2299 k.Editor.AddImage,
2300 k.Editor.PasteImage,
2301 k.Editor.MentionFile,
2302 k.Editor.OpenEditor,
2303 },
2304 )
2305 if hasAttachments {
2306 binds = append(binds,
2307 []key.Binding{
2308 k.Editor.AttachmentDeleteMode,
2309 k.Editor.DeleteAllAttachments,
2310 k.Editor.Escape,
2311 },
2312 )
2313 }
2314 binds = append(binds,
2315 []key.Binding{
2316 help,
2317 },
2318 )
2319 }
2320 }
2321
2322 binds = append(binds,
2323 []key.Binding{
2324 help,
2325 k.Quit,
2326 },
2327 )
2328
2329 return binds
2330}
2331
2332// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2333func (m *UI) toggleCompactMode() tea.Cmd {
2334 m.forceCompactMode = !m.forceCompactMode
2335
2336 err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2337 if err != nil {
2338 return util.ReportError(err)
2339 }
2340
2341 m.updateLayoutAndSize()
2342
2343 return nil
2344}
2345
2346// updateLayoutAndSize updates the layout and sizes of UI components.
2347func (m *UI) updateLayoutAndSize() {
2348 // Determine if we should be in compact mode
2349 if m.state == uiChat {
2350 if m.forceCompactMode {
2351 m.isCompact = true
2352 return
2353 }
2354 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2355 m.isCompact = true
2356 } else {
2357 m.isCompact = false
2358 }
2359 }
2360
2361 m.layout = m.generateLayout(m.width, m.height)
2362 m.updateSize()
2363}
2364
2365// updateSize updates the sizes of UI components based on the current layout.
2366func (m *UI) updateSize() {
2367 // Set status width
2368 m.status.SetWidth(m.layout.status.Dx())
2369
2370 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2371 m.textarea.SetWidth(m.layout.editor.Dx())
2372 // TODO: Abstract the textarea and attachments into a single editor
2373 // component so we don't have to manually account for the attachments
2374 // height here.
2375 m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2376 m.renderPills()
2377
2378 // Handle different app states
2379 switch m.state {
2380 case uiChat:
2381 if !m.isCompact {
2382 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2383 }
2384 }
2385}
2386
2387// generateLayout calculates the layout rectangles for all UI components based
2388// on the current UI state and terminal dimensions.
2389func (m *UI) generateLayout(w, h int) uiLayout {
2390 // The screen area we're working with
2391 area := image.Rect(0, 0, w, h)
2392
2393 // The help height
2394 helpHeight := 1
2395 // The editor height
2396 editorHeight := 5
2397 // The sidebar width
2398 sidebarWidth := 30
2399 // The header height
2400 const landingHeaderHeight = 4
2401
2402 var helpKeyMap help.KeyMap = m
2403 if m.status != nil && m.status.ShowingAll() {
2404 for _, row := range helpKeyMap.FullHelp() {
2405 helpHeight = max(helpHeight, len(row))
2406 }
2407 }
2408
2409 // Add app margins
2410 appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2411 appRect.Min.Y += 1
2412 appRect.Max.Y -= 1
2413 helpRect.Min.Y -= 1
2414 appRect.Min.X += 1
2415 appRect.Max.X -= 1
2416
2417 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2418 // extra padding on left and right for these states
2419 appRect.Min.X += 1
2420 appRect.Max.X -= 1
2421 }
2422
2423 uiLayout := uiLayout{
2424 area: area,
2425 status: helpRect,
2426 }
2427
2428 // Handle different app states
2429 switch m.state {
2430 case uiOnboarding, uiInitialize:
2431 // Layout
2432 //
2433 // header
2434 // ------
2435 // main
2436 // ------
2437 // help
2438
2439 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2440 uiLayout.header = headerRect
2441 uiLayout.main = mainRect
2442
2443 case uiLanding:
2444 // Layout
2445 //
2446 // header
2447 // ------
2448 // main
2449 // ------
2450 // editor
2451 // ------
2452 // help
2453 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2454 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2455 // Remove extra padding from editor (but keep it for header and main)
2456 editorRect.Min.X -= 1
2457 editorRect.Max.X += 1
2458 uiLayout.header = headerRect
2459 uiLayout.main = mainRect
2460 uiLayout.editor = editorRect
2461
2462 case uiChat:
2463 if m.isCompact {
2464 // Layout
2465 //
2466 // compact-header
2467 // ------
2468 // main
2469 // ------
2470 // editor
2471 // ------
2472 // help
2473 const compactHeaderHeight = 1
2474 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2475 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2476 sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2477 uiLayout.sessionDetails = sessionDetailsArea
2478 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2479 // Add one line gap between header and main content
2480 mainRect.Min.Y += 1
2481 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2482 mainRect.Max.X -= 1 // Add padding right
2483 uiLayout.header = headerRect
2484 pillsHeight := m.pillsAreaHeight()
2485 if pillsHeight > 0 {
2486 pillsHeight = min(pillsHeight, mainRect.Dy())
2487 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2488 uiLayout.main = chatRect
2489 uiLayout.pills = pillsRect
2490 } else {
2491 uiLayout.main = mainRect
2492 }
2493 // Add bottom margin to main
2494 uiLayout.main.Max.Y -= 1
2495 uiLayout.editor = editorRect
2496 } else {
2497 // Layout
2498 //
2499 // ------|---
2500 // main |
2501 // ------| side
2502 // editor|
2503 // ----------
2504 // help
2505
2506 mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2507 // Add padding left
2508 sideRect.Min.X += 1
2509 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2510 mainRect.Max.X -= 1 // Add padding right
2511 uiLayout.sidebar = sideRect
2512 pillsHeight := m.pillsAreaHeight()
2513 if pillsHeight > 0 {
2514 pillsHeight = min(pillsHeight, mainRect.Dy())
2515 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2516 uiLayout.main = chatRect
2517 uiLayout.pills = pillsRect
2518 } else {
2519 uiLayout.main = mainRect
2520 }
2521 // Add bottom margin to main
2522 uiLayout.main.Max.Y -= 1
2523 uiLayout.editor = editorRect
2524 }
2525 }
2526
2527 return uiLayout
2528}
2529
2530// uiLayout defines the positioning of UI elements.
2531type uiLayout struct {
2532 // area is the overall available area.
2533 area uv.Rectangle
2534
2535 // header is the header shown in special cases
2536 // e.x when the sidebar is collapsed
2537 // or when in the landing page
2538 // or in init/config
2539 header uv.Rectangle
2540
2541 // main is the area for the main pane. (e.x chat, configure, landing)
2542 main uv.Rectangle
2543
2544 // pills is the area for the pills panel.
2545 pills uv.Rectangle
2546
2547 // editor is the area for the editor pane.
2548 editor uv.Rectangle
2549
2550 // sidebar is the area for the sidebar.
2551 sidebar uv.Rectangle
2552
2553 // status is the area for the status view.
2554 status uv.Rectangle
2555
2556 // session details is the area for the session details overlay in compact mode.
2557 sessionDetails uv.Rectangle
2558}
2559
2560func (m *UI) openEditor(value string) tea.Cmd {
2561 tmpfile, err := os.CreateTemp("", "msg_*.md")
2562 if err != nil {
2563 return util.ReportError(err)
2564 }
2565 defer tmpfile.Close() //nolint:errcheck
2566 if _, err := tmpfile.WriteString(value); err != nil {
2567 return util.ReportError(err)
2568 }
2569 cmd, err := editor.Command(
2570 "crush",
2571 tmpfile.Name(),
2572 editor.AtPosition(
2573 m.textarea.Line()+1,
2574 m.textarea.Column()+1,
2575 ),
2576 )
2577 if err != nil {
2578 return util.ReportError(err)
2579 }
2580 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2581 if err != nil {
2582 return util.ReportError(err)
2583 }
2584 content, err := os.ReadFile(tmpfile.Name())
2585 if err != nil {
2586 return util.ReportError(err)
2587 }
2588 if len(content) == 0 {
2589 return util.ReportWarn("Message is empty")
2590 }
2591 os.Remove(tmpfile.Name())
2592 return openEditorMsg{
2593 Text: strings.TrimSpace(string(content)),
2594 }
2595 })
2596}
2597
2598// setEditorPrompt configures the textarea prompt function based on whether
2599// yolo mode is enabled.
2600func (m *UI) setEditorPrompt(yolo bool) {
2601 if yolo {
2602 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2603 return
2604 }
2605 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2606}
2607
2608// normalPromptFunc returns the normal editor prompt style (" > " on first
2609// line, "::: " on subsequent lines).
2610func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2611 t := m.com.Styles
2612 if info.LineNumber == 0 {
2613 if info.Focused {
2614 return " > "
2615 }
2616 return "::: "
2617 }
2618 if info.Focused {
2619 return t.EditorPromptNormalFocused.Render()
2620 }
2621 return t.EditorPromptNormalBlurred.Render()
2622}
2623
2624// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2625// and colored dots.
2626func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2627 t := m.com.Styles
2628 if info.LineNumber == 0 {
2629 if info.Focused {
2630 return t.EditorPromptYoloIconFocused.Render()
2631 } else {
2632 return t.EditorPromptYoloIconBlurred.Render()
2633 }
2634 }
2635 if info.Focused {
2636 return t.EditorPromptYoloDotsFocused.Render()
2637 }
2638 return t.EditorPromptYoloDotsBlurred.Render()
2639}
2640
2641// closeCompletions closes the completions popup and resets state.
2642func (m *UI) closeCompletions() {
2643 m.completionsOpen = false
2644 m.completionsQuery = ""
2645 m.completionsStartIndex = 0
2646 m.completions.Close()
2647}
2648
2649// insertCompletionText replaces the @query in the textarea with the given text.
2650// Returns false if the replacement cannot be performed.
2651func (m *UI) insertCompletionText(text string) bool {
2652 value := m.textarea.Value()
2653 if m.completionsStartIndex > len(value) {
2654 return false
2655 }
2656
2657 word := m.textareaWord()
2658 endIdx := min(m.completionsStartIndex+len(word), len(value))
2659 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2660 m.textarea.SetValue(newValue)
2661 m.textarea.MoveToEnd()
2662 m.textarea.InsertRune(' ')
2663 return true
2664}
2665
2666// insertFileCompletion inserts the selected file path into the textarea,
2667// replacing the @query, and adds the file as an attachment.
2668func (m *UI) insertFileCompletion(path string) tea.Cmd {
2669 if !m.insertCompletionText(path) {
2670 return nil
2671 }
2672
2673 return func() tea.Msg {
2674 absPath, _ := filepath.Abs(path)
2675
2676 if m.hasSession() {
2677 // Skip attachment if file was already read and hasn't been modified.
2678 lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2679 if !lastRead.IsZero() {
2680 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2681 return nil
2682 }
2683 }
2684 } else if slices.Contains(m.sessionFileReads, absPath) {
2685 return nil
2686 }
2687
2688 m.sessionFileReads = append(m.sessionFileReads, absPath)
2689
2690 // Add file as attachment.
2691 content, err := os.ReadFile(path)
2692 if err != nil {
2693 // If it fails, let the LLM handle it later.
2694 return nil
2695 }
2696
2697 return message.Attachment{
2698 FilePath: path,
2699 FileName: filepath.Base(path),
2700 MimeType: mimeOf(content),
2701 Content: content,
2702 }
2703 }
2704}
2705
2706// insertMCPResourceCompletion inserts the selected resource into the textarea,
2707// replacing the @query, and adds the resource as an attachment.
2708func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2709 displayText := cmp.Or(item.Title, item.URI)
2710
2711 if !m.insertCompletionText(displayText) {
2712 return nil
2713 }
2714
2715 return func() tea.Msg {
2716 contents, err := m.com.Workspace.ReadMCPResource(
2717 context.Background(),
2718 item.MCPName,
2719 item.URI,
2720 )
2721 if err != nil {
2722 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2723 return nil
2724 }
2725 if len(contents) == 0 {
2726 return nil
2727 }
2728
2729 content := contents[0]
2730 var data []byte
2731 if content.Text != "" {
2732 data = []byte(content.Text)
2733 } else if len(content.Blob) > 0 {
2734 data = content.Blob
2735 }
2736 if len(data) == 0 {
2737 return nil
2738 }
2739
2740 mimeType := item.MIMEType
2741 if mimeType == "" && content.MIMEType != "" {
2742 mimeType = content.MIMEType
2743 }
2744 if mimeType == "" {
2745 mimeType = "text/plain"
2746 }
2747
2748 return message.Attachment{
2749 FilePath: item.URI,
2750 FileName: displayText,
2751 MimeType: mimeType,
2752 Content: data,
2753 }
2754 }
2755}
2756
2757// completionsPosition returns the X and Y position for the completions popup.
2758func (m *UI) completionsPosition() image.Point {
2759 cur := m.textarea.Cursor()
2760 if cur == nil {
2761 return image.Point{
2762 X: m.layout.editor.Min.X,
2763 Y: m.layout.editor.Min.Y,
2764 }
2765 }
2766 return image.Point{
2767 X: cur.X + m.layout.editor.Min.X,
2768 Y: m.layout.editor.Min.Y + cur.Y,
2769 }
2770}
2771
2772// textareaWord returns the current word at the cursor position.
2773func (m *UI) textareaWord() string {
2774 return m.textarea.Word()
2775}
2776
2777// isWhitespace returns true if the byte is a whitespace character.
2778func isWhitespace(b byte) bool {
2779 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2780}
2781
2782// isAgentBusy returns true if the agent coordinator exists and is currently
2783// busy processing a request.
2784func (m *UI) isAgentBusy() bool {
2785 return m.com.Workspace.AgentIsReady() &&
2786 m.com.Workspace.AgentIsBusy()
2787}
2788
2789// hasSession returns true if there is an active session with a valid ID.
2790func (m *UI) hasSession() bool {
2791 return m.session != nil && m.session.ID != ""
2792}
2793
2794// mimeOf detects the MIME type of the given content.
2795func mimeOf(content []byte) string {
2796 mimeBufferSize := min(512, len(content))
2797 return http.DetectContentType(content[:mimeBufferSize])
2798}
2799
2800var readyPlaceholders = [...]string{
2801 "Ready!",
2802 "Ready...",
2803 "Ready?",
2804 "Ready for instructions",
2805}
2806
2807var workingPlaceholders = [...]string{
2808 "Working!",
2809 "Working...",
2810 "Brrrrr...",
2811 "Prrrrrrrr...",
2812 "Processing...",
2813 "Thinking...",
2814}
2815
2816// randomizePlaceholders selects random placeholder text for the textarea's
2817// ready and working states.
2818func (m *UI) randomizePlaceholders() {
2819 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2820 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2821}
2822
2823// renderEditorView renders the editor view with attachments if any.
2824func (m *UI) renderEditorView(width int) string {
2825 var attachmentsView string
2826 if len(m.attachments.List()) > 0 {
2827 attachmentsView = m.attachments.Render(width)
2828 }
2829 return strings.Join([]string{
2830 attachmentsView,
2831 m.textarea.View(),
2832 "", // margin at bottom of editor
2833 }, "\n")
2834}
2835
2836// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2837func (m *UI) cacheSidebarLogo(width int) {
2838 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2839}
2840
2841// sendMessage sends a message with the given content and attachments.
2842func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2843 if !m.com.Workspace.AgentIsReady() {
2844 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2845 }
2846
2847 var cmds []tea.Cmd
2848 if !m.hasSession() {
2849 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
2850 if err != nil {
2851 return util.ReportError(err)
2852 }
2853 if m.forceCompactMode {
2854 m.isCompact = true
2855 }
2856 if newSession.ID != "" {
2857 m.session = &newSession
2858 cmds = append(cmds, m.loadSession(newSession.ID))
2859 }
2860 m.setState(uiChat, m.focus)
2861 }
2862
2863 ctx := context.Background()
2864 cmds = append(cmds, func() tea.Msg {
2865 for _, path := range m.sessionFileReads {
2866 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2867 m.com.Workspace.LSPStart(ctx, path)
2868 }
2869 return nil
2870 })
2871
2872 // Capture session ID to avoid race with main goroutine updating m.session.
2873 sessionID := m.session.ID
2874 cmds = append(cmds, func() tea.Msg {
2875 err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
2876 if err != nil {
2877 isCancelErr := errors.Is(err, context.Canceled)
2878 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2879 if isCancelErr || isPermissionErr {
2880 return nil
2881 }
2882 return util.InfoMsg{
2883 Type: util.InfoTypeError,
2884 Msg: fmt.Sprintf("Failed to run agent: %v", err),
2885 }
2886 }
2887 return nil
2888 })
2889 return tea.Batch(cmds...)
2890}
2891
2892const cancelTimerDuration = 2 * time.Second
2893
2894// cancelTimerCmd creates a command that expires the cancel timer.
2895func cancelTimerCmd() tea.Cmd {
2896 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2897 return cancelTimerExpiredMsg{}
2898 })
2899}
2900
2901// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2902// and starts a timer. The second press (before the timer expires) actually
2903// cancels the agent.
2904func (m *UI) cancelAgent() tea.Cmd {
2905 if !m.hasSession() {
2906 return nil
2907 }
2908
2909 if !m.com.Workspace.AgentIsReady() {
2910 return nil
2911 }
2912
2913 if m.isCanceling {
2914 // Second escape press - actually cancel the agent.
2915 m.isCanceling = false
2916 m.com.Workspace.AgentCancel(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 m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2925 m.com.Workspace.AgentClearQueue(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 commands.InitialCmd()
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.Workspace.LSPStopAll(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.Workspace.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 := m.com.Workspace.GetMCPPrompt(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.Workspace.UpdateAgentModel(context.Background())
3423 return mcpStateChangedMsg{
3424 states: m.com.Workspace.MCPGetStates(),
3425 }
3426 }
3427}
3428
3429func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3430 return func() tea.Msg {
3431 ws.MCPRefreshPrompts(context.Background(), name)
3432 return nil
3433 }
3434}
3435
3436func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3437 return func() tea.Msg {
3438 ws.RefreshMCPTools(context.Background(), name)
3439 return nil
3440 }
3441}
3442
3443func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3444 return func() tea.Msg {
3445 ws.MCPRefreshResources(context.Background(), name)
3446 return nil
3447 }
3448}
3449
3450func (m *UI) copyChatHighlight() tea.Cmd {
3451 text := m.chat.HighlightContent()
3452 return common.CopyToClipboardWithCallback(
3453 text,
3454 "Selected text copied to clipboard",
3455 func() tea.Msg {
3456 m.chat.ClearMouse()
3457 return nil
3458 },
3459 )
3460}
3461
3462func (m *UI) enableDockerMCP() tea.Msg {
3463 ctx := context.Background()
3464 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3465 return util.ReportError(err)()
3466 }
3467
3468 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3469}
3470
3471func (m *UI) disableDockerMCP() tea.Msg {
3472 if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3473 return util.ReportError(err)()
3474 }
3475
3476 return util.NewInfoMsg("Docker MCP disabled successfully")
3477}
3478
3479// renderLogo renders the Crush logo with the given styles and dimensions.
3480func renderLogo(t *styles.Styles, compact bool, width int) string {
3481 return logo.Render(t, version.Version, compact, logo.Opts{
3482 FieldColor: t.LogoFieldColor,
3483 TitleColorA: t.LogoTitleColorA,
3484 TitleColorB: t.LogoTitleColorB,
3485 CharmColor: t.LogoCharmColor,
3486 VersionColor: t.LogoVersionColor,
3487 Width: width,
3488 })
3489}