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