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