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