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