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