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 tmpPath := tmpfile.Name()
2615 defer tmpfile.Close() //nolint:errcheck
2616 if _, err := tmpfile.WriteString(value); err != nil {
2617 return util.ReportError(err)
2618 }
2619 cmd, err := editor.Command(
2620 "crush",
2621 tmpPath,
2622 editor.AtPosition(
2623 m.textarea.Line()+1,
2624 m.textarea.Column()+1,
2625 ),
2626 )
2627 if err != nil {
2628 return util.ReportError(err)
2629 }
2630 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2631 defer func() {
2632 _ = os.Remove(tmpPath)
2633 }()
2634
2635 if err != nil {
2636 return util.ReportError(err)
2637 }
2638 content, err := os.ReadFile(tmpPath)
2639 if err != nil {
2640 return util.ReportError(err)
2641 }
2642 if len(content) == 0 {
2643 return util.ReportWarn("Message is empty")
2644 }
2645 return openEditorMsg{
2646 Text: strings.TrimSpace(string(content)),
2647 }
2648 })
2649}
2650
2651// setEditorPrompt configures the textarea prompt function based on whether
2652// yolo mode is enabled.
2653func (m *UI) setEditorPrompt(yolo bool) {
2654 if yolo {
2655 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2656 return
2657 }
2658 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2659}
2660
2661// normalPromptFunc returns the normal editor prompt style (" > " on first
2662// line, "::: " on subsequent lines).
2663func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2664 t := m.com.Styles
2665 if info.LineNumber == 0 {
2666 if info.Focused {
2667 return " > "
2668 }
2669 return "::: "
2670 }
2671 if info.Focused {
2672 return t.EditorPromptNormalFocused.Render()
2673 }
2674 return t.EditorPromptNormalBlurred.Render()
2675}
2676
2677// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2678// and colored dots.
2679func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2680 t := m.com.Styles
2681 if info.LineNumber == 0 {
2682 if info.Focused {
2683 return t.EditorPromptYoloIconFocused.Render()
2684 } else {
2685 return t.EditorPromptYoloIconBlurred.Render()
2686 }
2687 }
2688 if info.Focused {
2689 return t.EditorPromptYoloDotsFocused.Render()
2690 }
2691 return t.EditorPromptYoloDotsBlurred.Render()
2692}
2693
2694// closeCompletions closes the completions popup and resets state.
2695func (m *UI) closeCompletions() {
2696 m.completionsOpen = false
2697 m.completionsQuery = ""
2698 m.completionsStartIndex = 0
2699 m.completions.Close()
2700}
2701
2702// insertCompletionText replaces the @query in the textarea with the given text.
2703// Returns false if the replacement cannot be performed.
2704func (m *UI) insertCompletionText(text string) bool {
2705 value := m.textarea.Value()
2706 if m.completionsStartIndex > len(value) {
2707 return false
2708 }
2709
2710 word := m.textareaWord()
2711 endIdx := min(m.completionsStartIndex+len(word), len(value))
2712 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2713 m.textarea.SetValue(newValue)
2714 m.textarea.MoveToEnd()
2715 m.textarea.InsertRune(' ')
2716 return true
2717}
2718
2719// insertFileCompletion inserts the selected file path into the textarea,
2720// replacing the @query, and adds the file as an attachment.
2721func (m *UI) insertFileCompletion(path string) tea.Cmd {
2722 prevHeight := m.textarea.Height()
2723 if !m.insertCompletionText(path) {
2724 return nil
2725 }
2726 heightCmd := m.handleTextareaHeightChange(prevHeight)
2727
2728 fileCmd := func() tea.Msg {
2729 absPath, _ := filepath.Abs(path)
2730
2731 if m.hasSession() {
2732 // Skip attachment if file was already read and hasn't been modified.
2733 lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2734 if !lastRead.IsZero() {
2735 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2736 return nil
2737 }
2738 }
2739 } else if slices.Contains(m.sessionFileReads, absPath) {
2740 return nil
2741 }
2742
2743 m.sessionFileReads = append(m.sessionFileReads, absPath)
2744
2745 // Add file as attachment.
2746 content, err := os.ReadFile(path)
2747 if err != nil {
2748 // If it fails, let the LLM handle it later.
2749 return nil
2750 }
2751
2752 return message.Attachment{
2753 FilePath: path,
2754 FileName: filepath.Base(path),
2755 MimeType: mimeOf(content),
2756 Content: content,
2757 }
2758 }
2759 return tea.Batch(heightCmd, fileCmd)
2760}
2761
2762// insertMCPResourceCompletion inserts the selected resource into the textarea,
2763// replacing the @query, and adds the resource as an attachment.
2764func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2765 displayText := cmp.Or(item.Title, item.URI)
2766
2767 prevHeight := m.textarea.Height()
2768 if !m.insertCompletionText(displayText) {
2769 return nil
2770 }
2771 heightCmd := m.handleTextareaHeightChange(prevHeight)
2772
2773 resourceCmd := func() tea.Msg {
2774 contents, err := mcp.ReadResource(
2775 context.Background(),
2776 m.com.Store(),
2777 item.MCPName,
2778 item.URI,
2779 )
2780 if err != nil {
2781 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2782 return nil
2783 }
2784 if len(contents) == 0 {
2785 return nil
2786 }
2787
2788 content := contents[0]
2789 var data []byte
2790 if content.Text != "" {
2791 data = []byte(content.Text)
2792 } else if len(content.Blob) > 0 {
2793 data = content.Blob
2794 }
2795 if len(data) == 0 {
2796 return nil
2797 }
2798
2799 mimeType := item.MIMEType
2800 if mimeType == "" && content.MIMEType != "" {
2801 mimeType = content.MIMEType
2802 }
2803 if mimeType == "" {
2804 mimeType = "text/plain"
2805 }
2806
2807 return message.Attachment{
2808 FilePath: item.URI,
2809 FileName: displayText,
2810 MimeType: mimeType,
2811 Content: data,
2812 }
2813 }
2814 return tea.Batch(heightCmd, resourceCmd)
2815}
2816
2817// completionsPosition returns the X and Y position for the completions popup.
2818func (m *UI) completionsPosition() image.Point {
2819 cur := m.textarea.Cursor()
2820 if cur == nil {
2821 return image.Point{
2822 X: m.layout.editor.Min.X,
2823 Y: m.layout.editor.Min.Y,
2824 }
2825 }
2826 return image.Point{
2827 X: cur.X + m.layout.editor.Min.X,
2828 Y: m.layout.editor.Min.Y + cur.Y,
2829 }
2830}
2831
2832// textareaWord returns the current word at the cursor position.
2833func (m *UI) textareaWord() string {
2834 return m.textarea.Word()
2835}
2836
2837// isWhitespace returns true if the byte is a whitespace character.
2838func isWhitespace(b byte) bool {
2839 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2840}
2841
2842// isAgentBusy returns true if the agent coordinator exists and is currently
2843// busy processing a request.
2844func (m *UI) isAgentBusy() bool {
2845 return m.com.App != nil &&
2846 m.com.App.AgentCoordinator != nil &&
2847 m.com.App.AgentCoordinator.IsBusy()
2848}
2849
2850// hasSession returns true if there is an active session with a valid ID.
2851func (m *UI) hasSession() bool {
2852 return m.session != nil && m.session.ID != ""
2853}
2854
2855// mimeOf detects the MIME type of the given content.
2856func mimeOf(content []byte) string {
2857 mimeBufferSize := min(512, len(content))
2858 return http.DetectContentType(content[:mimeBufferSize])
2859}
2860
2861var readyPlaceholders = [...]string{
2862 "Ready!",
2863 "Ready...",
2864 "Ready?",
2865 "Ready for instructions",
2866}
2867
2868var workingPlaceholders = [...]string{
2869 "Working!",
2870 "Working...",
2871 "Brrrrr...",
2872 "Prrrrrrrr...",
2873 "Processing...",
2874 "Thinking...",
2875}
2876
2877// randomizePlaceholders selects random placeholder text for the textarea's
2878// ready and working states.
2879func (m *UI) randomizePlaceholders() {
2880 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2881 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2882}
2883
2884// renderEditorView renders the editor view with attachments if any.
2885func (m *UI) renderEditorView(width int) string {
2886 var attachmentsView string
2887 if len(m.attachments.List()) > 0 {
2888 attachmentsView = m.attachments.Render(width)
2889 }
2890 return strings.Join([]string{
2891 attachmentsView,
2892 m.textarea.View(),
2893 "", // margin at bottom of editor
2894 }, "\n")
2895}
2896
2897// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2898func (m *UI) cacheSidebarLogo(width int) {
2899 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2900}
2901
2902// sendMessage sends a message with the given content and attachments.
2903func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2904 if m.com.App.AgentCoordinator == nil {
2905 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2906 }
2907
2908 var cmds []tea.Cmd
2909 if !m.hasSession() {
2910 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2911 if err != nil {
2912 return util.ReportError(err)
2913 }
2914 if m.forceCompactMode {
2915 m.isCompact = true
2916 }
2917 if newSession.ID != "" {
2918 m.session = &newSession
2919 cmds = append(cmds, m.loadSession(newSession.ID))
2920 }
2921 m.setState(uiChat, m.focus)
2922 }
2923
2924 ctx := context.Background()
2925 cmds = append(cmds, func() tea.Msg {
2926 for _, path := range m.sessionFileReads {
2927 m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2928 m.com.App.LSPManager.Start(ctx, path)
2929 }
2930 return nil
2931 })
2932
2933 // Capture session ID to avoid race with main goroutine updating m.session.
2934 sessionID := m.session.ID
2935 cmds = append(cmds, func() tea.Msg {
2936 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2937 if err != nil {
2938 isCancelErr := errors.Is(err, context.Canceled)
2939 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2940 if isCancelErr || isPermissionErr {
2941 return nil
2942 }
2943 return util.InfoMsg{
2944 Type: util.InfoTypeError,
2945 Msg: err.Error(),
2946 }
2947 }
2948 return nil
2949 })
2950 return tea.Batch(cmds...)
2951}
2952
2953const cancelTimerDuration = 2 * time.Second
2954
2955// cancelTimerCmd creates a command that expires the cancel timer.
2956func cancelTimerCmd() tea.Cmd {
2957 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2958 return cancelTimerExpiredMsg{}
2959 })
2960}
2961
2962// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2963// and starts a timer. The second press (before the timer expires) actually
2964// cancels the agent.
2965func (m *UI) cancelAgent() tea.Cmd {
2966 if !m.hasSession() {
2967 return nil
2968 }
2969
2970 coordinator := m.com.App.AgentCoordinator
2971 if coordinator == nil {
2972 return nil
2973 }
2974
2975 if m.isCanceling {
2976 // Second escape press - actually cancel the agent.
2977 m.isCanceling = false
2978 coordinator.Cancel(m.session.ID)
2979 // Stop the spinning todo indicator.
2980 m.todoIsSpinning = false
2981 m.renderPills()
2982 return nil
2983 }
2984
2985 // Check if there are queued prompts - if so, clear the queue.
2986 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2987 coordinator.ClearQueue(m.session.ID)
2988 return nil
2989 }
2990
2991 // First escape press - set canceling state and start timer.
2992 m.isCanceling = true
2993 return cancelTimerCmd()
2994}
2995
2996// openDialog opens a dialog by its ID.
2997func (m *UI) openDialog(id string) tea.Cmd {
2998 var cmds []tea.Cmd
2999 switch id {
3000 case dialog.SessionsID:
3001 if cmd := m.openSessionsDialog(); cmd != nil {
3002 cmds = append(cmds, cmd)
3003 }
3004 case dialog.ModelsID:
3005 if cmd := m.openModelsDialog(); cmd != nil {
3006 cmds = append(cmds, cmd)
3007 }
3008 case dialog.CommandsID:
3009 if cmd := m.openCommandsDialog(); cmd != nil {
3010 cmds = append(cmds, cmd)
3011 }
3012 case dialog.ReasoningID:
3013 if cmd := m.openReasoningDialog(); cmd != nil {
3014 cmds = append(cmds, cmd)
3015 }
3016 case dialog.FilePickerID:
3017 if cmd := m.openFilesDialog(); cmd != nil {
3018 cmds = append(cmds, cmd)
3019 }
3020 case dialog.QuitID:
3021 if cmd := m.openQuitDialog(); cmd != nil {
3022 cmds = append(cmds, cmd)
3023 }
3024 default:
3025 // Unknown dialog
3026 break
3027 }
3028 return tea.Batch(cmds...)
3029}
3030
3031// openQuitDialog opens the quit confirmation dialog.
3032func (m *UI) openQuitDialog() tea.Cmd {
3033 if m.dialog.ContainsDialog(dialog.QuitID) {
3034 // Bring to front
3035 m.dialog.BringToFront(dialog.QuitID)
3036 return nil
3037 }
3038
3039 quitDialog := dialog.NewQuit(m.com)
3040 m.dialog.OpenDialog(quitDialog)
3041 return nil
3042}
3043
3044// openModelsDialog opens the models dialog.
3045func (m *UI) openModelsDialog() tea.Cmd {
3046 if m.dialog.ContainsDialog(dialog.ModelsID) {
3047 // Bring to front
3048 m.dialog.BringToFront(dialog.ModelsID)
3049 return nil
3050 }
3051
3052 isOnboarding := m.state == uiOnboarding
3053 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3054 if err != nil {
3055 return util.ReportError(err)
3056 }
3057
3058 m.dialog.OpenDialog(modelsDialog)
3059
3060 return nil
3061}
3062
3063// openCommandsDialog opens the commands dialog.
3064func (m *UI) openCommandsDialog() tea.Cmd {
3065 if m.dialog.ContainsDialog(dialog.CommandsID) {
3066 // Bring to front
3067 m.dialog.BringToFront(dialog.CommandsID)
3068 return nil
3069 }
3070
3071 var sessionID string
3072 hasSession := m.session != nil
3073 if hasSession {
3074 sessionID = m.session.ID
3075 }
3076 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3077 hasQueue := m.promptQueue > 0
3078
3079 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3080 if err != nil {
3081 return util.ReportError(err)
3082 }
3083
3084 m.dialog.OpenDialog(commands)
3085
3086 return commands.InitialCmd()
3087}
3088
3089// openReasoningDialog opens the reasoning effort dialog.
3090func (m *UI) openReasoningDialog() tea.Cmd {
3091 if m.dialog.ContainsDialog(dialog.ReasoningID) {
3092 m.dialog.BringToFront(dialog.ReasoningID)
3093 return nil
3094 }
3095
3096 reasoningDialog, err := dialog.NewReasoning(m.com)
3097 if err != nil {
3098 return util.ReportError(err)
3099 }
3100
3101 m.dialog.OpenDialog(reasoningDialog)
3102 return nil
3103}
3104
3105// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3106// it brings it to the front. Otherwise, it will list all the sessions and open
3107// the dialog.
3108func (m *UI) openSessionsDialog() tea.Cmd {
3109 if m.dialog.ContainsDialog(dialog.SessionsID) {
3110 // Bring to front
3111 m.dialog.BringToFront(dialog.SessionsID)
3112 return nil
3113 }
3114
3115 selectedSessionID := ""
3116 if m.session != nil {
3117 selectedSessionID = m.session.ID
3118 }
3119
3120 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3121 if err != nil {
3122 return util.ReportError(err)
3123 }
3124
3125 m.dialog.OpenDialog(dialog)
3126 return nil
3127}
3128
3129// openFilesDialog opens the file picker dialog.
3130func (m *UI) openFilesDialog() tea.Cmd {
3131 if m.dialog.ContainsDialog(dialog.FilePickerID) {
3132 // Bring to front
3133 m.dialog.BringToFront(dialog.FilePickerID)
3134 return nil
3135 }
3136
3137 filePicker, cmd := dialog.NewFilePicker(m.com)
3138 filePicker.SetImageCapabilities(&m.caps)
3139 m.dialog.OpenDialog(filePicker)
3140
3141 return cmd
3142}
3143
3144// openPermissionsDialog opens the permissions dialog for a permission request.
3145func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3146 // Close any existing permissions dialog first.
3147 m.dialog.CloseDialog(dialog.PermissionsID)
3148
3149 // Get diff mode from config.
3150 var opts []dialog.PermissionsOption
3151 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3152 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3153 }
3154
3155 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3156 m.dialog.OpenDialog(permDialog)
3157 return nil
3158}
3159
3160// handlePermissionNotification updates tool items when permission state changes.
3161func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3162 toolItem := m.chat.MessageItem(notification.ToolCallID)
3163 if toolItem == nil {
3164 return
3165 }
3166
3167 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3168 if notification.Granted {
3169 permItem.SetStatus(chat.ToolStatusRunning)
3170 } else {
3171 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3172 }
3173 }
3174}
3175
3176// handleAgentNotification translates domain agent events into desktop
3177// notifications using the UI notification backend.
3178func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3179 switch n.Type {
3180 case notify.TypeAgentFinished:
3181 return m.sendNotification(notification.Notification{
3182 Title: "Crush is waiting...",
3183 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3184 })
3185 default:
3186 return nil
3187 }
3188}
3189
3190// newSession clears the current session state and prepares for a new session.
3191// The actual session creation happens when the user sends their first message.
3192// Returns a command to reload prompt history.
3193func (m *UI) newSession() tea.Cmd {
3194 if !m.hasSession() {
3195 return nil
3196 }
3197
3198 m.session = nil
3199 m.sessionFiles = nil
3200 m.sessionFileReads = nil
3201 m.setState(uiLanding, uiFocusEditor)
3202 m.textarea.Focus()
3203 m.chat.Blur()
3204 m.chat.ClearMessages()
3205 m.pillsExpanded = false
3206 m.promptQueue = 0
3207 m.pillsView = ""
3208 m.historyReset()
3209 agenttools.ResetCache()
3210 return tea.Batch(
3211 func() tea.Msg {
3212 m.com.App.LSPManager.StopAll(context.Background())
3213 return nil
3214 },
3215 m.loadPromptHistory(),
3216 )
3217}
3218
3219// handlePasteMsg handles a paste message.
3220func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3221 if m.dialog.HasDialogs() {
3222 return m.handleDialogMsg(msg)
3223 }
3224
3225 if m.focus != uiFocusEditor {
3226 return nil
3227 }
3228
3229 if hasPasteExceededThreshold(msg) {
3230 return func() tea.Msg {
3231 content := []byte(msg.Content)
3232 if int64(len(content)) > common.MaxAttachmentSize {
3233 return util.ReportWarn("Paste is too big (>5mb)")
3234 }
3235 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3236 mimeBufferSize := min(512, len(content))
3237 mimeType := http.DetectContentType(content[:mimeBufferSize])
3238 return message.Attachment{
3239 FileName: name,
3240 FilePath: name,
3241 MimeType: mimeType,
3242 Content: content,
3243 }
3244 }
3245 }
3246
3247 // Attempt to parse pasted content as file paths. If possible to parse,
3248 // all files exist and are valid, add as attachments.
3249 // Otherwise, paste as text.
3250 paths := fsext.ParsePastedFiles(msg.Content)
3251 allExistsAndValid := func() bool {
3252 if len(paths) == 0 {
3253 return false
3254 }
3255 for _, path := range paths {
3256 if _, err := os.Stat(path); os.IsNotExist(err) {
3257 return false
3258 }
3259
3260 lowerPath := strings.ToLower(path)
3261 isValid := false
3262 for _, ext := range common.AllowedImageTypes {
3263 if strings.HasSuffix(lowerPath, ext) {
3264 isValid = true
3265 break
3266 }
3267 }
3268 if !isValid {
3269 return false
3270 }
3271 }
3272 return true
3273 }
3274 if !allExistsAndValid() {
3275 prevHeight := m.textarea.Height()
3276 return m.updateTextareaWithPrevHeight(msg, prevHeight)
3277 }
3278
3279 var cmds []tea.Cmd
3280 for _, path := range paths {
3281 cmds = append(cmds, m.handleFilePathPaste(path))
3282 }
3283 return tea.Batch(cmds...)
3284}
3285
3286func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3287 var (
3288 lineCount = 0
3289 colCount = 0
3290 )
3291 for line := range strings.SplitSeq(msg.Content, "\n") {
3292 lineCount++
3293 colCount = max(colCount, len(line))
3294
3295 if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3296 return true
3297 }
3298 }
3299 return false
3300}
3301
3302// handleFilePathPaste handles a pasted file path.
3303func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3304 return func() tea.Msg {
3305 fileInfo, err := os.Stat(path)
3306 if err != nil {
3307 return util.ReportError(err)
3308 }
3309 if fileInfo.IsDir() {
3310 return util.ReportWarn("Cannot attach a directory")
3311 }
3312 if fileInfo.Size() > common.MaxAttachmentSize {
3313 return util.ReportWarn("File is too big (>5mb)")
3314 }
3315
3316 content, err := os.ReadFile(path)
3317 if err != nil {
3318 return util.ReportError(err)
3319 }
3320
3321 mimeBufferSize := min(512, len(content))
3322 mimeType := http.DetectContentType(content[:mimeBufferSize])
3323 fileName := filepath.Base(path)
3324 return message.Attachment{
3325 FilePath: path,
3326 FileName: fileName,
3327 MimeType: mimeType,
3328 Content: content,
3329 }
3330 }
3331}
3332
3333// pasteImageFromClipboard reads image data from the system clipboard and
3334// creates an attachment. If no image data is found, it falls back to
3335// interpreting clipboard text as a file path.
3336func (m *UI) pasteImageFromClipboard() tea.Msg {
3337 imageData, err := readClipboard(clipboardFormatImage)
3338 if int64(len(imageData)) > common.MaxAttachmentSize {
3339 return util.InfoMsg{
3340 Type: util.InfoTypeError,
3341 Msg: "File too large, max 5MB",
3342 }
3343 }
3344 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3345 if err == nil {
3346 return message.Attachment{
3347 FilePath: name,
3348 FileName: name,
3349 MimeType: mimeOf(imageData),
3350 Content: imageData,
3351 }
3352 }
3353
3354 textData, textErr := readClipboard(clipboardFormatText)
3355 if textErr != nil || len(textData) == 0 {
3356 return nil // Clipboard is empty or does not contain an image
3357 }
3358
3359 path := strings.TrimSpace(string(textData))
3360 path = strings.ReplaceAll(path, "\\ ", " ")
3361 if _, statErr := os.Stat(path); statErr != nil {
3362 return nil // Clipboard does not contain an image or valid file path
3363 }
3364
3365 lowerPath := strings.ToLower(path)
3366 isAllowed := false
3367 for _, ext := range common.AllowedImageTypes {
3368 if strings.HasSuffix(lowerPath, ext) {
3369 isAllowed = true
3370 break
3371 }
3372 }
3373 if !isAllowed {
3374 return util.NewInfoMsg("File type is not a supported image format")
3375 }
3376
3377 fileInfo, statErr := os.Stat(path)
3378 if statErr != nil {
3379 return util.InfoMsg{
3380 Type: util.InfoTypeError,
3381 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3382 }
3383 }
3384 if fileInfo.Size() > common.MaxAttachmentSize {
3385 return util.InfoMsg{
3386 Type: util.InfoTypeError,
3387 Msg: "File too large, max 5MB",
3388 }
3389 }
3390
3391 content, readErr := os.ReadFile(path)
3392 if readErr != nil {
3393 return util.InfoMsg{
3394 Type: util.InfoTypeError,
3395 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3396 }
3397 }
3398
3399 return message.Attachment{
3400 FilePath: path,
3401 FileName: filepath.Base(path),
3402 MimeType: mimeOf(content),
3403 Content: content,
3404 }
3405}
3406
3407var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3408
3409func (m *UI) pasteIdx() int {
3410 result := 0
3411 for _, at := range m.attachments.List() {
3412 found := pasteRE.FindStringSubmatch(at.FileName)
3413 if len(found) == 0 {
3414 continue
3415 }
3416 idx, err := strconv.Atoi(found[1])
3417 if err == nil {
3418 result = max(result, idx)
3419 }
3420 }
3421 return result + 1
3422}
3423
3424// drawSessionDetails draws the session details in compact mode.
3425func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3426 if m.session == nil {
3427 return
3428 }
3429
3430 s := m.com.Styles
3431
3432 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3433 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3434
3435 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3436 blocks := []string{
3437 title,
3438 "",
3439 m.modelInfo(width),
3440 "",
3441 }
3442
3443 detailsHeader := lipgloss.JoinVertical(
3444 lipgloss.Left,
3445 blocks...,
3446 )
3447
3448 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3449
3450 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3451
3452 const maxSectionWidth = 50
3453 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3454 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3455
3456 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3457 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3458 filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3459 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3460 uv.NewStyledString(
3461 s.CompactDetails.View.
3462 Width(area.Dx()).
3463 Render(
3464 lipgloss.JoinVertical(
3465 lipgloss.Left,
3466 detailsHeader,
3467 sections,
3468 version,
3469 ),
3470 ),
3471 ).Draw(scr, area)
3472}
3473
3474func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3475 load := func() tea.Msg {
3476 prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3477 if err != nil {
3478 // TODO: make this better
3479 return util.ReportError(err)()
3480 }
3481
3482 if prompt == "" {
3483 return nil
3484 }
3485 return sendMessageMsg{
3486 Content: prompt,
3487 }
3488 }
3489
3490 var cmds []tea.Cmd
3491 if cmd := m.dialog.StartLoading(); cmd != nil {
3492 cmds = append(cmds, cmd)
3493 }
3494 cmds = append(cmds, load, func() tea.Msg {
3495 return closeDialogMsg{}
3496 })
3497
3498 return tea.Sequence(cmds...)
3499}
3500
3501func (m *UI) handleStateChanged() tea.Cmd {
3502 return func() tea.Msg {
3503 m.com.App.UpdateAgentModel(context.Background())
3504 return mcpStateChangedMsg{
3505 states: mcp.GetStates(),
3506 }
3507 }
3508}
3509
3510func handleMCPPromptsEvent(name string) tea.Cmd {
3511 return func() tea.Msg {
3512 mcp.RefreshPrompts(context.Background(), name)
3513 return nil
3514 }
3515}
3516
3517func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3518 return func() tea.Msg {
3519 mcp.RefreshTools(
3520 context.Background(),
3521 cfg,
3522 name,
3523 )
3524 return nil
3525 }
3526}
3527
3528func handleMCPResourcesEvent(name string) tea.Cmd {
3529 return func() tea.Msg {
3530 mcp.RefreshResources(context.Background(), name)
3531 return nil
3532 }
3533}
3534
3535func (m *UI) copyChatHighlight() tea.Cmd {
3536 text := m.chat.HighlightContent()
3537 return common.CopyToClipboardWithCallback(
3538 text,
3539 "Selected text copied to clipboard",
3540 func() tea.Msg {
3541 m.chat.ClearMouse()
3542 return nil
3543 },
3544 )
3545}
3546
3547func (m *UI) enableDockerMCP() tea.Msg {
3548 store := m.com.Store()
3549 // Stage Docker MCP in memory first so startup and persistence can be atomic.
3550 mcpConfig, err := store.PrepareDockerMCPConfig()
3551 if err != nil {
3552 return util.ReportError(err)()
3553 }
3554
3555 ctx := context.Background()
3556 if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3557 // Roll back runtime and in-memory state when startup fails.
3558 disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3559 delete(store.Config().MCP, config.DockerMCPName)
3560 return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
3561 }
3562
3563 if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
3564 // Roll back runtime and in-memory state if persistence fails.
3565 disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3566 delete(store.Config().MCP, config.DockerMCPName)
3567 return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
3568 }
3569
3570 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3571}
3572
3573func (m *UI) disableDockerMCP() tea.Msg {
3574 store := m.com.Store()
3575 // Close the Docker MCP client.
3576 if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3577 return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3578 }
3579
3580 // Remove from config and persist.
3581 if err := store.DisableDockerMCP(); err != nil {
3582 return util.ReportError(err)()
3583 }
3584
3585 return util.NewInfoMsg("Docker MCP disabled successfully")
3586}
3587
3588// renderLogo renders the Crush logo with the given styles and dimensions.
3589func renderLogo(t *styles.Styles, compact bool, width int) string {
3590 return logo.Render(t, version.Version, compact, logo.Opts{
3591 FieldColor: t.LogoFieldColor,
3592 TitleColorA: t.LogoTitleColorA,
3593 TitleColorB: t.LogoTitleColorB,
3594 CharmColor: t.LogoCharmColor,
3595 VersionColor: t.LogoVersionColor,
3596 Width: width,
3597 })
3598}