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 // we ignore dialogs with the oauth id as they need to be able to be dismissed
1625 if m.isAgentBusy() && !m.dialog.ContainsDialog(dialog.OAuthID) {
1626 return util.ReportWarn("Agent is busy, please wait...")
1627 }
1628
1629 cfg := m.com.Config()
1630 if cfg == nil {
1631 return util.ReportError(errors.New("configuration not found"))
1632 }
1633
1634 var (
1635 providerID = msg.Model.Provider
1636 isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1637 isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1638 isOnboarding = m.state == uiOnboarding
1639 )
1640
1641 // For Hyper, if the stored OAuth token is expired, try a silent
1642 // refresh before deciding whether the provider is configured. Keeps
1643 // users from hitting a 401 on their first message after the
1644 // short-lived access token ages out.
1645 if !msg.ReAuthenticate && providerID == "hyper" {
1646 if pc, ok := cfg.Providers.Get(providerID); ok && pc.OAuthToken != nil && pc.OAuthToken.IsExpired() {
1647 return m.refreshHyperAndRetrySelect(msg)
1648 }
1649 }
1650
1651 // Attempt to import GitHub Copilot tokens from VSCode if available.
1652 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1653 m.com.Workspace.ImportCopilot()
1654 }
1655
1656 if !isConfigured() || msg.ReAuthenticate {
1657 m.dialog.CloseDialog(dialog.ModelsID)
1658 if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1659 cmds = append(cmds, cmd)
1660 }
1661 return tea.Batch(cmds...)
1662 }
1663
1664 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1665 cmds = append(cmds, util.ReportError(err))
1666 } else {
1667 if msg.ModelType == config.SelectedModelTypeLarge {
1668 // Swap the theme live based on the newly selected large
1669 // model's provider.
1670 m.applyTheme(styles.ThemeForProvider(providerID))
1671 }
1672 if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1673 // Ensure small model is set is unset.
1674 smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1675 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1676 cmds = append(cmds, util.ReportError(err))
1677 }
1678 }
1679 }
1680
1681 cmds = append(cmds, func() tea.Msg {
1682 if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1683 return util.ReportError(err)
1684 }
1685
1686 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1687
1688 return util.NewInfoMsg(modelMsg)
1689 })
1690
1691 m.dialog.CloseDialog(dialog.APIKeyInputID)
1692 m.dialog.CloseDialog(dialog.OAuthID)
1693 m.dialog.CloseDialog(dialog.ModelsID)
1694
1695 if isOnboarding {
1696 m.setState(uiLanding, uiFocusEditor)
1697 m.com.Config().SetupAgents()
1698 if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1699 cmds = append(cmds, util.ReportError(err))
1700 }
1701 } else if m.com.IsHyper() {
1702 cmds = append(cmds, m.fetchHyperCredits())
1703 }
1704
1705 return tea.Batch(cmds...)
1706}
1707
1708func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1709 var (
1710 dlg dialog.Dialog
1711 cmd tea.Cmd
1712
1713 isOnboarding = m.state == uiOnboarding
1714 )
1715
1716 switch provider.ID {
1717 case "hyper":
1718 dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1719 case catwalk.InferenceProviderCopilot:
1720 dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1721 default:
1722 dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1723 }
1724
1725 if m.dialog.ContainsDialog(dlg.ID()) {
1726 m.dialog.BringToFront(dlg.ID())
1727 return nil
1728 }
1729
1730 m.dialog.OpenDialog(dlg)
1731 return cmd
1732}
1733
1734func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1735 var cmds []tea.Cmd
1736
1737 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1738 switch {
1739 case key.Matches(msg, m.keyMap.Help):
1740 m.status.ToggleHelp()
1741 m.updateLayoutAndSize()
1742 return true
1743 case key.Matches(msg, m.keyMap.Commands):
1744 if cmd := m.openCommandsDialog(); cmd != nil {
1745 cmds = append(cmds, cmd)
1746 }
1747 return true
1748 case key.Matches(msg, m.keyMap.Models):
1749 if cmd := m.openModelsDialog(); cmd != nil {
1750 cmds = append(cmds, cmd)
1751 }
1752 return true
1753 case key.Matches(msg, m.keyMap.Sessions):
1754 if cmd := m.openSessionsDialog(); cmd != nil {
1755 cmds = append(cmds, cmd)
1756 }
1757 return true
1758 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1759 m.detailsOpen = !m.detailsOpen
1760 m.updateLayoutAndSize()
1761 return true
1762 case key.Matches(msg, m.keyMap.Chat.TogglePills):
1763 if m.state == uiChat && m.hasSession() {
1764 if cmd := m.togglePillsExpanded(); cmd != nil {
1765 cmds = append(cmds, cmd)
1766 }
1767 return true
1768 }
1769 case key.Matches(msg, m.keyMap.Chat.PillLeft):
1770 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1771 if cmd := m.switchPillSection(-1); cmd != nil {
1772 cmds = append(cmds, cmd)
1773 }
1774 return true
1775 }
1776 case key.Matches(msg, m.keyMap.Chat.PillRight):
1777 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1778 if cmd := m.switchPillSection(1); cmd != nil {
1779 cmds = append(cmds, cmd)
1780 }
1781 return true
1782 }
1783 case key.Matches(msg, m.keyMap.Suspend):
1784 if m.isAgentBusy() {
1785 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1786 return true
1787 }
1788 cmds = append(cmds, tea.Suspend)
1789 return true
1790 }
1791 return false
1792 }
1793
1794 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1795 // Always handle quit keys first
1796 if cmd := m.openQuitDialog(); cmd != nil {
1797 cmds = append(cmds, cmd)
1798 }
1799
1800 return tea.Batch(cmds...)
1801 }
1802
1803 // Route all messages to dialog if one is open.
1804 if m.dialog.HasDialogs() {
1805 return m.handleDialogMsg(msg)
1806 }
1807
1808 // Handle cancel key when agent is busy.
1809 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1810 if m.isAgentBusy() {
1811 if cmd := m.cancelAgent(); cmd != nil {
1812 cmds = append(cmds, cmd)
1813 }
1814 return tea.Batch(cmds...)
1815 }
1816 }
1817
1818 switch m.state {
1819 case uiOnboarding:
1820 return tea.Batch(cmds...)
1821 case uiInitialize:
1822 cmds = append(cmds, m.updateInitializeView(msg)...)
1823 return tea.Batch(cmds...)
1824 case uiChat, uiLanding:
1825 switch m.focus {
1826 case uiFocusEditor:
1827 // Handle completions if open.
1828 if m.completionsOpen {
1829 if msg, ok := m.completions.Update(msg); ok {
1830 switch msg := msg.(type) {
1831 case completions.SelectionMsg[completions.FileCompletionValue]:
1832 cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1833 if !msg.KeepOpen {
1834 m.closeCompletions()
1835 }
1836 case completions.SelectionMsg[completions.ResourceCompletionValue]:
1837 cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1838 if !msg.KeepOpen {
1839 m.closeCompletions()
1840 }
1841 case completions.ClosedMsg:
1842 m.completionsOpen = false
1843 }
1844 return tea.Batch(cmds...)
1845 }
1846 }
1847
1848 if ok := m.attachments.Update(msg); ok {
1849 return tea.Batch(cmds...)
1850 }
1851
1852 switch {
1853 case key.Matches(msg, m.keyMap.Editor.AddImage):
1854 if !m.currentModelSupportsImages() {
1855 break
1856 }
1857 if cmd := m.openFilesDialog(); cmd != nil {
1858 cmds = append(cmds, cmd)
1859 }
1860
1861 case key.Matches(msg, m.keyMap.Editor.PasteImage):
1862 if !m.currentModelSupportsImages() {
1863 break
1864 }
1865 cmds = append(cmds, m.pasteImageFromClipboard)
1866
1867 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1868 prevHeight := m.textarea.Height()
1869 value := m.textarea.Value()
1870 if before, ok := strings.CutSuffix(value, "\\"); ok {
1871 // If the last character is a backslash, remove it and add a newline.
1872 m.textarea.SetValue(before)
1873 if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1874 cmds = append(cmds, cmd)
1875 }
1876 break
1877 }
1878
1879 // Otherwise, send the message
1880 m.textarea.Reset()
1881 if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1882 cmds = append(cmds, cmd)
1883 }
1884
1885 value = strings.TrimSpace(value)
1886 if value == "exit" || value == "quit" {
1887 return m.openQuitDialog()
1888 }
1889
1890 attachments := m.attachments.List()
1891 m.attachments.Reset()
1892 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1893 return nil
1894 }
1895
1896 m.randomizePlaceholders()
1897 m.historyReset()
1898
1899 return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1900 case key.Matches(msg, m.keyMap.Chat.NewSession):
1901 if !m.hasSession() {
1902 break
1903 }
1904 if m.isAgentBusy() {
1905 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1906 break
1907 }
1908 if cmd := m.newSession(); cmd != nil {
1909 cmds = append(cmds, cmd)
1910 }
1911 case key.Matches(msg, m.keyMap.Tab):
1912 if m.state != uiLanding {
1913 m.setState(m.state, uiFocusMain)
1914 m.textarea.Blur()
1915 m.chat.Focus()
1916 m.chat.SetSelected(m.chat.Len() - 1)
1917 }
1918 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1919 if m.isAgentBusy() {
1920 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1921 break
1922 }
1923 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1924 case key.Matches(msg, m.keyMap.Editor.Newline):
1925 prevHeight := m.textarea.Height()
1926 m.textarea.InsertRune('\n')
1927 m.closeCompletions()
1928 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1929 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1930 cmd := m.handleHistoryUp(msg)
1931 if cmd != nil {
1932 cmds = append(cmds, cmd)
1933 }
1934 case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1935 cmd := m.handleHistoryDown(msg)
1936 if cmd != nil {
1937 cmds = append(cmds, cmd)
1938 }
1939 case key.Matches(msg, m.keyMap.Editor.Escape):
1940 cmd := m.handleHistoryEscape(msg)
1941 if cmd != nil {
1942 cmds = append(cmds, cmd)
1943 }
1944 case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1945 if cmd := m.openCommandsDialog(); cmd != nil {
1946 cmds = append(cmds, cmd)
1947 }
1948 default:
1949 if handleGlobalKeys(msg) {
1950 // Handle global keys first before passing to textarea.
1951 break
1952 }
1953
1954 // Check for @ trigger before passing to textarea.
1955 curValue := m.textarea.Value()
1956 curIdx := len(curValue)
1957
1958 // Trigger completions on @.
1959 if msg.String() == "@" && !m.completionsOpen {
1960 // Only show if beginning of prompt or after whitespace.
1961 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1962 m.completionsOpen = true
1963 m.completionsQuery = ""
1964 m.completionsStartIndex = curIdx
1965 m.completionsPositionStart = m.completionsPosition()
1966 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1967 cmds = append(cmds, m.completions.Open(depth, limit))
1968 }
1969 }
1970
1971 // remove the details if they are open when user starts typing
1972 if m.detailsOpen {
1973 m.detailsOpen = false
1974 m.updateLayoutAndSize()
1975 }
1976
1977 prevHeight := m.textarea.Height()
1978 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1979
1980 // Any text modification becomes the current draft.
1981 m.updateHistoryDraft(curValue)
1982
1983 // After updating textarea, check if we need to filter completions.
1984 // Skip filtering on the initial @ keystroke since items are loading async.
1985 if m.completionsOpen && msg.String() != "@" {
1986 newValue := m.textarea.Value()
1987 newIdx := len(newValue)
1988
1989 // Close completions if cursor moved before start.
1990 if newIdx <= m.completionsStartIndex {
1991 m.closeCompletions()
1992 } else if msg.String() == "space" {
1993 // Close on space.
1994 m.closeCompletions()
1995 } else {
1996 // Extract current word and filter.
1997 word := m.textareaWord()
1998 if strings.HasPrefix(word, "@") {
1999 m.completionsQuery = word[1:]
2000 m.completions.Filter(m.completionsQuery)
2001 } else if m.completionsOpen {
2002 m.closeCompletions()
2003 }
2004 }
2005 }
2006 }
2007 case uiFocusMain:
2008 switch {
2009 case key.Matches(msg, m.keyMap.Tab):
2010 m.focus = uiFocusEditor
2011 cmds = append(cmds, m.textarea.Focus())
2012 m.chat.Blur()
2013 case key.Matches(msg, m.keyMap.Chat.NewSession):
2014 if !m.hasSession() {
2015 break
2016 }
2017 if m.isAgentBusy() {
2018 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
2019 break
2020 }
2021 m.focus = uiFocusEditor
2022 if cmd := m.newSession(); cmd != nil {
2023 cmds = append(cmds, cmd)
2024 }
2025 case key.Matches(msg, m.keyMap.Chat.Expand):
2026 m.chat.ToggleExpandedSelectedItem()
2027 case key.Matches(msg, m.keyMap.Chat.Up):
2028 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
2029 cmds = append(cmds, cmd)
2030 }
2031 if !m.chat.SelectedItemInView() {
2032 m.chat.SelectPrev()
2033 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2034 cmds = append(cmds, cmd)
2035 }
2036 }
2037 case key.Matches(msg, m.keyMap.Chat.Down):
2038 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
2039 cmds = append(cmds, cmd)
2040 }
2041 if !m.chat.SelectedItemInView() {
2042 m.chat.SelectNext()
2043 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2044 cmds = append(cmds, cmd)
2045 }
2046 }
2047 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
2048 m.chat.SelectPrev()
2049 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2050 cmds = append(cmds, cmd)
2051 }
2052 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
2053 m.chat.SelectNext()
2054 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2055 cmds = append(cmds, cmd)
2056 }
2057 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
2058 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
2059 cmds = append(cmds, cmd)
2060 }
2061 m.chat.SelectFirstInView()
2062 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
2063 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
2064 cmds = append(cmds, cmd)
2065 }
2066 m.chat.SelectLastInView()
2067 case key.Matches(msg, m.keyMap.Chat.PageUp):
2068 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
2069 cmds = append(cmds, cmd)
2070 }
2071 m.chat.SelectFirstInView()
2072 case key.Matches(msg, m.keyMap.Chat.PageDown):
2073 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
2074 cmds = append(cmds, cmd)
2075 }
2076 m.chat.SelectLastInView()
2077 case key.Matches(msg, m.keyMap.Chat.Home):
2078 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
2079 cmds = append(cmds, cmd)
2080 }
2081 m.chat.SelectFirst()
2082 case key.Matches(msg, m.keyMap.Chat.End):
2083 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
2084 cmds = append(cmds, cmd)
2085 }
2086 m.chat.SelectLast()
2087 default:
2088 if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
2089 cmds = append(cmds, cmd)
2090 } else {
2091 handleGlobalKeys(msg)
2092 }
2093 }
2094 default:
2095 handleGlobalKeys(msg)
2096 }
2097 default:
2098 handleGlobalKeys(msg)
2099 }
2100
2101 return tea.Sequence(cmds...)
2102}
2103
2104// drawHeader draws the header section of the UI.
2105func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
2106 m.header.drawHeader(
2107 scr,
2108 area,
2109 m.session,
2110 m.isCompact,
2111 m.detailsOpen,
2112 area.Dx(),
2113 m.hyperCredits,
2114 )
2115}
2116
2117// Draw implements [uv.Drawable] and draws the UI model.
2118func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
2119 layout := m.generateLayout(area.Dx(), area.Dy())
2120
2121 if m.layout != layout {
2122 m.layout = layout
2123 m.updateSize()
2124 }
2125
2126 // Clear the screen first
2127 screen.Clear(scr)
2128
2129 switch m.state {
2130 case uiOnboarding:
2131 m.drawHeader(scr, layout.header)
2132
2133 // NOTE: Onboarding flow will be rendered as dialogs below, but
2134 // positioned at the bottom left of the screen.
2135
2136 case uiInitialize:
2137 m.drawHeader(scr, layout.header)
2138
2139 main := uv.NewStyledString(m.initializeView())
2140 main.Draw(scr, layout.main)
2141
2142 case uiLanding:
2143 m.drawHeader(scr, layout.header)
2144 main := uv.NewStyledString(m.landingView())
2145 main.Draw(scr, layout.main)
2146
2147 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
2148 editor.Draw(scr, layout.editor)
2149
2150 case uiChat:
2151 if m.isCompact {
2152 m.drawHeader(scr, layout.header)
2153 } else {
2154 m.drawSidebar(scr, layout.sidebar)
2155 }
2156
2157 m.chat.Draw(scr, layout.main)
2158 if layout.pills.Dy() > 0 && m.pillsView != "" {
2159 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2160 }
2161
2162 editorWidth := scr.Bounds().Dx()
2163 if !m.isCompact {
2164 editorWidth -= layout.sidebar.Dx()
2165 }
2166 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2167 editor.Draw(scr, layout.editor)
2168
2169 // Draw details overlay in compact mode when open
2170 if m.isCompact && m.detailsOpen {
2171 m.drawSessionDetails(scr, layout.sessionDetails)
2172 }
2173 }
2174
2175 isOnboarding := m.state == uiOnboarding
2176
2177 // Add status and help layer
2178 m.status.SetHideHelp(isOnboarding)
2179 m.status.Draw(scr, layout.status)
2180
2181 // Draw completions popup if open
2182 if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2183 w, h := m.completions.Size()
2184 x := m.completionsPositionStart.X
2185 y := m.completionsPositionStart.Y - h
2186
2187 screenW := area.Dx()
2188 if x+w > screenW {
2189 x = screenW - w
2190 }
2191 x = max(0, x)
2192 y = max(0, y+1) // Offset for attachments row
2193
2194 completionsView := uv.NewStyledString(m.completions.Render())
2195 completionsView.Draw(scr, image.Rectangle{
2196 Min: image.Pt(x, y),
2197 Max: image.Pt(x+w, y+h),
2198 })
2199 }
2200
2201 // Debugging rendering (visually see when the tui rerenders)
2202 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2203 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2204 debug := uv.NewStyledString(debugView.String())
2205 debug.Draw(scr, image.Rectangle{
2206 Min: image.Pt(4, 1),
2207 Max: image.Pt(8, 3),
2208 })
2209 }
2210
2211 // This needs to come last to overlay on top of everything. We always pass
2212 // the full screen bounds because the dialogs will position themselves
2213 // accordingly.
2214 if m.dialog.HasDialogs() {
2215 return m.dialog.Draw(scr, scr.Bounds())
2216 }
2217
2218 switch m.focus {
2219 case uiFocusEditor:
2220 if m.layout.editor.Dy() <= 0 {
2221 // Don't show cursor if editor is not visible
2222 return nil
2223 }
2224 if m.detailsOpen && m.isCompact {
2225 // Don't show cursor if details overlay is open
2226 return nil
2227 }
2228
2229 if m.textarea.Focused() {
2230 cur := m.textarea.Cursor()
2231 cur.X++ // Adjust for app margins
2232 cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2233 return cur
2234 }
2235 }
2236 return nil
2237}
2238
2239// View renders the UI model's view.
2240func (m *UI) View() tea.View {
2241 var v tea.View
2242 v.AltScreen = true
2243 if !m.isTransparent {
2244 v.BackgroundColor = m.com.Styles.Background
2245 }
2246 v.MouseMode = tea.MouseModeCellMotion
2247 v.ReportFocus = m.caps.ReportFocusEvents
2248 v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2249
2250 canvas := uv.NewScreenBuffer(m.width, m.height)
2251 v.Cursor = m.Draw(canvas, canvas.Bounds())
2252
2253 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2254 contentLines := strings.Split(content, "\n")
2255 for i, line := range contentLines {
2256 // Trim trailing spaces for concise rendering
2257 contentLines[i] = strings.TrimRight(line, " ")
2258 }
2259
2260 content = strings.Join(contentLines, "\n")
2261
2262 v.Content = content
2263 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2264 // HACK: use a random percentage to prevent ghostty from hiding it
2265 // after a timeout.
2266 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2267 }
2268
2269 return v
2270}
2271
2272// ShortHelp implements [help.KeyMap].
2273func (m *UI) ShortHelp() []key.Binding {
2274 var binds []key.Binding
2275 k := &m.keyMap
2276 tab := k.Tab
2277 commands := k.Commands
2278 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2279 commands.SetHelp("/ or ctrl+p", "commands")
2280 }
2281
2282 switch m.state {
2283 case uiInitialize:
2284 binds = append(binds, k.Quit)
2285 case uiChat:
2286 // Show cancel binding if agent is busy.
2287 if m.isAgentBusy() {
2288 cancelBinding := k.Chat.Cancel
2289 if m.isCanceling {
2290 cancelBinding.SetHelp("esc", "press again to cancel")
2291 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2292 cancelBinding.SetHelp("esc", "clear queue")
2293 }
2294 binds = append(binds, cancelBinding)
2295 }
2296
2297 if m.focus == uiFocusEditor {
2298 tab.SetHelp("tab", "focus chat")
2299 } else {
2300 tab.SetHelp("tab", "focus editor")
2301 }
2302
2303 binds = append(binds,
2304 tab,
2305 commands,
2306 k.Models,
2307 )
2308
2309 switch m.focus {
2310 case uiFocusEditor:
2311 binds = append(binds,
2312 k.Editor.Newline,
2313 )
2314 case uiFocusMain:
2315 binds = append(binds,
2316 k.Chat.UpDown,
2317 k.Chat.UpDownOneItem,
2318 k.Chat.PageUp,
2319 k.Chat.PageDown,
2320 k.Chat.Copy,
2321 )
2322 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2323 binds = append(binds, k.Chat.PillLeft)
2324 }
2325 }
2326 default:
2327 // TODO: other states
2328 // if m.session == nil {
2329 // no session selected
2330 binds = append(binds,
2331 commands,
2332 k.Models,
2333 k.Editor.Newline,
2334 )
2335 }
2336
2337 binds = append(binds,
2338 k.Quit,
2339 k.Help,
2340 )
2341
2342 return binds
2343}
2344
2345// FullHelp implements [help.KeyMap].
2346func (m *UI) FullHelp() [][]key.Binding {
2347 var binds [][]key.Binding
2348 k := &m.keyMap
2349 help := k.Help
2350 help.SetHelp("ctrl+g", "less")
2351 hasAttachments := len(m.attachments.List()) > 0
2352 hasSession := m.hasSession()
2353 commands := k.Commands
2354 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2355 commands.SetHelp("/ or ctrl+p", "commands")
2356 }
2357
2358 switch m.state {
2359 case uiInitialize:
2360 binds = append(binds,
2361 []key.Binding{
2362 k.Quit,
2363 })
2364 case uiChat:
2365 // Show cancel binding if agent is busy.
2366 if m.isAgentBusy() {
2367 cancelBinding := k.Chat.Cancel
2368 if m.isCanceling {
2369 cancelBinding.SetHelp("esc", "press again to cancel")
2370 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2371 cancelBinding.SetHelp("esc", "clear queue")
2372 }
2373 binds = append(binds, []key.Binding{cancelBinding})
2374 }
2375
2376 mainBinds := []key.Binding{}
2377 tab := k.Tab
2378 if m.focus == uiFocusEditor {
2379 tab.SetHelp("tab", "focus chat")
2380 } else {
2381 tab.SetHelp("tab", "focus editor")
2382 }
2383
2384 mainBinds = append(mainBinds,
2385 tab,
2386 commands,
2387 k.Models,
2388 k.Sessions,
2389 )
2390 if hasSession {
2391 mainBinds = append(mainBinds, k.Chat.NewSession)
2392 }
2393
2394 binds = append(binds, mainBinds)
2395
2396 switch m.focus {
2397 case uiFocusEditor:
2398 editorBinds := []key.Binding{
2399 k.Editor.Newline,
2400 k.Editor.MentionFile,
2401 k.Editor.OpenEditor,
2402 }
2403 if m.currentModelSupportsImages() {
2404 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2405 }
2406 binds = append(binds, editorBinds)
2407 if hasAttachments {
2408 binds = append(binds,
2409 []key.Binding{
2410 k.Editor.AttachmentDeleteMode,
2411 k.Editor.DeleteAllAttachments,
2412 k.Editor.Escape,
2413 },
2414 )
2415 }
2416 case uiFocusMain:
2417 binds = append(binds,
2418 []key.Binding{
2419 k.Chat.UpDown,
2420 k.Chat.UpDownOneItem,
2421 k.Chat.PageUp,
2422 k.Chat.PageDown,
2423 },
2424 []key.Binding{
2425 k.Chat.HalfPageUp,
2426 k.Chat.HalfPageDown,
2427 k.Chat.Home,
2428 k.Chat.End,
2429 },
2430 []key.Binding{
2431 k.Chat.Copy,
2432 k.Chat.ClearHighlight,
2433 },
2434 )
2435 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2436 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2437 }
2438 }
2439 default:
2440 if m.session == nil {
2441 // no session selected
2442 binds = append(binds,
2443 []key.Binding{
2444 commands,
2445 k.Models,
2446 k.Sessions,
2447 },
2448 )
2449 editorBinds := []key.Binding{
2450 k.Editor.Newline,
2451 k.Editor.MentionFile,
2452 k.Editor.OpenEditor,
2453 }
2454 if m.currentModelSupportsImages() {
2455 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2456 }
2457 binds = append(binds, editorBinds)
2458 if hasAttachments {
2459 binds = append(binds,
2460 []key.Binding{
2461 k.Editor.AttachmentDeleteMode,
2462 k.Editor.DeleteAllAttachments,
2463 k.Editor.Escape,
2464 },
2465 )
2466 }
2467 }
2468 }
2469
2470 binds = append(binds,
2471 []key.Binding{
2472 help,
2473 k.Quit,
2474 },
2475 )
2476
2477 return binds
2478}
2479
2480func (m *UI) currentModelSupportsImages() bool {
2481 cfg := m.com.Config()
2482 if cfg == nil {
2483 return false
2484 }
2485 agentCfg, ok := cfg.Agents[config.AgentCoder]
2486 if !ok {
2487 return false
2488 }
2489 model := cfg.GetModelByType(agentCfg.Model)
2490 return model != nil && model.SupportsImages
2491}
2492
2493// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2494func (m *UI) toggleCompactMode() tea.Cmd {
2495 m.forceCompactMode = !m.forceCompactMode
2496
2497 err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2498 if err != nil {
2499 return util.ReportError(err)
2500 }
2501
2502 m.updateLayoutAndSize()
2503
2504 return nil
2505}
2506
2507// updateLayoutAndSize updates the layout and sizes of UI components.
2508func (m *UI) updateLayoutAndSize() {
2509 // Determine if we should be in compact mode
2510 if m.state == uiChat {
2511 if m.forceCompactMode {
2512 m.isCompact = true
2513 } else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2514 m.isCompact = true
2515 } else {
2516 m.isCompact = false
2517 }
2518 }
2519
2520 // First pass sizes components from the current textarea height.
2521 m.layout = m.generateLayout(m.width, m.height)
2522 prevHeight := m.textarea.Height()
2523 m.updateSize()
2524
2525 // SetWidth can change textarea height due to soft-wrap recalculation.
2526 // If that happens, run one reconciliation pass with the new height.
2527 if m.textarea.Height() != prevHeight {
2528 m.layout = m.generateLayout(m.width, m.height)
2529 m.updateSize()
2530 }
2531}
2532
2533// handleTextareaHeightChange checks whether the textarea height changed and,
2534// if so, recalculates the layout. When the chat is in follow mode it keeps
2535// the view scrolled to the bottom. The returned command, if non-nil, must be
2536// batched by the caller.
2537func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
2538 if m.textarea.Height() == prevHeight {
2539 return nil
2540 }
2541 m.updateLayoutAndSize()
2542 if m.state == uiChat && m.chat.Follow() {
2543 return m.chat.ScrollToBottomAndAnimate()
2544 }
2545 return nil
2546}
2547
2548// updateTextarea updates the textarea for msg and then reconciles layout if
2549// the textarea height changed as a result.
2550func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
2551 return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
2552}
2553
2554// updateTextareaWithPrevHeight is for cases when the height of the layout may
2555// have changed.
2556//
2557// Particularly, it's for cases where the textarea changes before
2558// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
2559// pass the height from before those changes took place so we can compare
2560// "before" vs "after" sizing and recalculate the layout if the textarea grew
2561// or shrank.
2562func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2563 ta, cmd := m.textarea.Update(msg)
2564 m.textarea = ta
2565 return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
2566}
2567
2568// updateSize updates the sizes of UI components based on the current layout.
2569func (m *UI) updateSize() {
2570 // Set status width
2571 m.status.SetWidth(m.layout.status.Dx())
2572
2573 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2574 m.textarea.MaxHeight = TextareaMaxHeight
2575 m.textarea.SetWidth(m.layout.editor.Dx())
2576 m.renderPills()
2577
2578 // Handle different app states
2579 switch m.state {
2580 case uiChat:
2581 if !m.isCompact {
2582 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2583 }
2584 }
2585}
2586
2587// generateLayout calculates the layout rectangles for all UI components based
2588// on the current UI state and terminal dimensions.
2589func (m *UI) generateLayout(w, h int) uiLayout {
2590 // The screen area we're working with
2591 area := image.Rect(0, 0, w, h)
2592
2593 // The help height
2594 helpHeight := 1
2595 // The editor height: textarea height + margin for attachments and bottom spacing.
2596 editorHeight := m.textarea.Height() + editorHeightMargin
2597 // The sidebar width
2598 sidebarWidth := 30
2599 // The header height
2600 const landingHeaderHeight = 4
2601
2602 var helpKeyMap help.KeyMap = m
2603 if m.status != nil && m.status.ShowingAll() {
2604 for _, row := range helpKeyMap.FullHelp() {
2605 helpHeight = max(helpHeight, len(row))
2606 }
2607 }
2608
2609 // Add app margins
2610 var appRect, helpRect image.Rectangle
2611 layout.Vertical(
2612 layout.Len(area.Dy()-helpHeight),
2613 layout.Fill(1),
2614 ).Split(area).Assign(&appRect, &helpRect)
2615 appRect.Min.Y += 1
2616 appRect.Max.Y -= 1
2617 helpRect.Min.Y -= 1
2618 appRect.Min.X += 1
2619 appRect.Max.X -= 1
2620
2621 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2622 // extra padding on left and right for these states
2623 appRect.Min.X += 1
2624 appRect.Max.X -= 1
2625 }
2626
2627 uiLayout := uiLayout{
2628 area: area,
2629 status: helpRect,
2630 }
2631
2632 // Handle different app states
2633 switch m.state {
2634 case uiOnboarding, uiInitialize:
2635 // Layout
2636 //
2637 // header
2638 // ------
2639 // main
2640 // ------
2641 // help
2642
2643 var headerRect, mainRect image.Rectangle
2644 layout.Vertical(
2645 layout.Len(landingHeaderHeight),
2646 layout.Fill(1),
2647 ).Split(appRect).Assign(&headerRect, &mainRect)
2648 uiLayout.header = headerRect
2649 uiLayout.main = mainRect
2650
2651 case uiLanding:
2652 // Layout
2653 //
2654 // header
2655 // ------
2656 // main
2657 // ------
2658 // editor
2659 // ------
2660 // help
2661 var headerRect, mainRect image.Rectangle
2662 layout.Vertical(
2663 layout.Len(landingHeaderHeight),
2664 layout.Fill(1),
2665 ).Split(appRect).Assign(&headerRect, &mainRect)
2666 var editorRect image.Rectangle
2667 layout.Vertical(
2668 layout.Len(mainRect.Dy()-editorHeight),
2669 layout.Fill(1),
2670 ).Split(mainRect).Assign(&mainRect, &editorRect)
2671 // Remove extra padding from editor (but keep it for header and main)
2672 editorRect.Min.X -= 1
2673 editorRect.Max.X += 1
2674 uiLayout.header = headerRect
2675 uiLayout.main = mainRect
2676 uiLayout.editor = editorRect
2677
2678 case uiChat:
2679 if m.isCompact {
2680 // Layout
2681 //
2682 // compact-header
2683 // ------
2684 // main
2685 // ------
2686 // editor
2687 // ------
2688 // help
2689 const compactHeaderHeight = 1
2690 var headerRect, mainRect image.Rectangle
2691 layout.Vertical(
2692 layout.Len(compactHeaderHeight),
2693 layout.Fill(1),
2694 ).Split(appRect).Assign(&headerRect, &mainRect)
2695 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2696 var sessionDetailsArea image.Rectangle
2697 layout.Vertical(
2698 layout.Len(detailsHeight),
2699 layout.Fill(1),
2700 ).Split(appRect).Assign(&sessionDetailsArea, new(image.Rectangle))
2701 uiLayout.sessionDetails = sessionDetailsArea
2702 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2703 // Add one line gap between header and main content
2704 mainRect.Min.Y += 1
2705 var editorRect image.Rectangle
2706 layout.Vertical(
2707 layout.Len(mainRect.Dy()-editorHeight),
2708 layout.Fill(1),
2709 ).Split(mainRect).Assign(&mainRect, &editorRect)
2710 mainRect.Max.X -= 1 // Add padding right
2711 uiLayout.header = headerRect
2712 pillsHeight := m.pillsAreaHeight()
2713 if pillsHeight > 0 {
2714 pillsHeight = min(pillsHeight, mainRect.Dy())
2715 var chatRect, pillsRect image.Rectangle
2716 layout.Vertical(
2717 layout.Len(mainRect.Dy()-pillsHeight),
2718 layout.Fill(1),
2719 ).Split(mainRect).Assign(&chatRect, &pillsRect)
2720 uiLayout.main = chatRect
2721 uiLayout.pills = pillsRect
2722 } else {
2723 uiLayout.main = mainRect
2724 }
2725 // Add bottom margin to main
2726 uiLayout.main.Max.Y -= 1
2727 uiLayout.editor = editorRect
2728 } else {
2729 // Layout
2730 //
2731 // ------|---
2732 // main |
2733 // ------| side
2734 // editor|
2735 // ----------
2736 // help
2737
2738 var mainRect, sideRect image.Rectangle
2739 layout.Horizontal(
2740 layout.Len(appRect.Dx()-sidebarWidth),
2741 layout.Fill(1),
2742 ).Split(appRect).Assign(&mainRect, &sideRect)
2743 // Add padding left
2744 sideRect.Min.X += 1
2745 var editorRect image.Rectangle
2746 layout.Vertical(
2747 layout.Len(mainRect.Dy()-editorHeight),
2748 layout.Fill(1),
2749 ).Split(mainRect).Assign(&mainRect, &editorRect)
2750 mainRect.Max.X -= 1 // Add padding right
2751 uiLayout.sidebar = sideRect
2752 pillsHeight := m.pillsAreaHeight()
2753 if pillsHeight > 0 {
2754 pillsHeight = min(pillsHeight, mainRect.Dy())
2755 var chatRect, pillsRect image.Rectangle
2756 layout.Vertical(
2757 layout.Len(mainRect.Dy()-pillsHeight),
2758 layout.Fill(1),
2759 ).Split(mainRect).Assign(&chatRect, &pillsRect)
2760 uiLayout.main = chatRect
2761 uiLayout.pills = pillsRect
2762 } else {
2763 uiLayout.main = mainRect
2764 }
2765 // Add bottom margin to main
2766 uiLayout.main.Max.Y -= 1
2767 uiLayout.editor = editorRect
2768 }
2769 }
2770
2771 return uiLayout
2772}
2773
2774// uiLayout defines the positioning of UI elements.
2775type uiLayout struct {
2776 // area is the overall available area.
2777 area uv.Rectangle
2778
2779 // header is the header shown in special cases
2780 // e.x when the sidebar is collapsed
2781 // or when in the landing page
2782 // or in init/config
2783 header uv.Rectangle
2784
2785 // main is the area for the main pane. (e.x chat, configure, landing)
2786 main uv.Rectangle
2787
2788 // pills is the area for the pills panel.
2789 pills uv.Rectangle
2790
2791 // editor is the area for the editor pane.
2792 editor uv.Rectangle
2793
2794 // sidebar is the area for the sidebar.
2795 sidebar uv.Rectangle
2796
2797 // status is the area for the status view.
2798 status uv.Rectangle
2799
2800 // session details is the area for the session details overlay in compact mode.
2801 sessionDetails uv.Rectangle
2802}
2803
2804func (m *UI) openEditor(value string) tea.Cmd {
2805 tmpfile, err := os.CreateTemp("", "msg_*.md")
2806 if err != nil {
2807 return util.ReportError(err)
2808 }
2809 tmpPath := tmpfile.Name()
2810 defer tmpfile.Close() //nolint:errcheck
2811 if _, err := tmpfile.WriteString(value); err != nil {
2812 return util.ReportError(err)
2813 }
2814 cmd, err := editor.Command(
2815 "crush",
2816 tmpPath,
2817 editor.AtPosition(
2818 m.textarea.Line()+1,
2819 m.textarea.Column()+1,
2820 ),
2821 )
2822 if err != nil {
2823 return util.ReportError(err)
2824 }
2825 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2826 defer func() {
2827 _ = os.Remove(tmpPath)
2828 }()
2829
2830 if err != nil {
2831 return util.ReportError(err)
2832 }
2833 content, err := os.ReadFile(tmpPath)
2834 if err != nil {
2835 return util.ReportError(err)
2836 }
2837 if len(content) == 0 {
2838 return util.ReportWarn("Message is empty")
2839 }
2840 return openEditorMsg{
2841 Text: strings.TrimSpace(string(content)),
2842 }
2843 })
2844}
2845
2846// setEditorPrompt configures the textarea prompt function based on whether
2847// yolo mode is enabled.
2848func (m *UI) setEditorPrompt(yolo bool) {
2849 if yolo {
2850 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2851 return
2852 }
2853 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2854}
2855
2856// normalPromptFunc returns the normal editor prompt style (" > " on first
2857// line, "::: " on subsequent lines).
2858func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2859 t := m.com.Styles
2860 if info.LineNumber == 0 {
2861 if info.Focused {
2862 return " > "
2863 }
2864 return "::: "
2865 }
2866 if info.Focused {
2867 return t.Editor.PromptNormalFocused.Render()
2868 }
2869 return t.Editor.PromptNormalBlurred.Render()
2870}
2871
2872// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2873// and colored dots.
2874func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2875 t := m.com.Styles
2876 if info.LineNumber == 0 {
2877 if info.Focused {
2878 return t.Editor.PromptYoloIconFocused.Render()
2879 } else {
2880 return t.Editor.PromptYoloIconBlurred.Render()
2881 }
2882 }
2883 if info.Focused {
2884 return t.Editor.PromptYoloDotsFocused.Render()
2885 }
2886 return t.Editor.PromptYoloDotsBlurred.Render()
2887}
2888
2889// closeCompletions closes the completions popup and resets state.
2890func (m *UI) closeCompletions() {
2891 m.completionsOpen = false
2892 m.completionsQuery = ""
2893 m.completionsStartIndex = 0
2894 m.completions.Close()
2895}
2896
2897// insertCompletionText replaces the @query in the textarea with the given text.
2898// Returns false if the replacement cannot be performed.
2899func (m *UI) insertCompletionText(text string) bool {
2900 value := m.textarea.Value()
2901 if m.completionsStartIndex > len(value) {
2902 return false
2903 }
2904
2905 word := m.textareaWord()
2906 endIdx := min(m.completionsStartIndex+len(word), len(value))
2907 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2908 m.textarea.SetValue(newValue)
2909 m.textarea.MoveToEnd()
2910 m.textarea.InsertRune(' ')
2911 return true
2912}
2913
2914// insertFileCompletion inserts the selected file path into the textarea,
2915// replacing the @query, and adds the file as an attachment.
2916func (m *UI) insertFileCompletion(path string) tea.Cmd {
2917 prevHeight := m.textarea.Height()
2918 if !m.insertCompletionText(path) {
2919 return nil
2920 }
2921 heightCmd := m.handleTextareaHeightChange(prevHeight)
2922
2923 fileCmd := func() tea.Msg {
2924 absPath, _ := filepath.Abs(path)
2925
2926 if m.hasSession() {
2927 // Skip attachment if file was already read and hasn't been modified.
2928 lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2929 if !lastRead.IsZero() {
2930 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2931 return nil
2932 }
2933 }
2934 } else if slices.Contains(m.sessionFileReads, absPath) {
2935 return nil
2936 }
2937
2938 m.sessionFileReads = append(m.sessionFileReads, absPath)
2939
2940 // Add file as attachment.
2941 content, err := os.ReadFile(path)
2942 if err != nil {
2943 // If it fails, let the LLM handle it later.
2944 return nil
2945 }
2946
2947 return message.Attachment{
2948 FilePath: path,
2949 FileName: filepath.Base(path),
2950 MimeType: mimeOf(content),
2951 Content: content,
2952 }
2953 }
2954 return tea.Batch(heightCmd, fileCmd)
2955}
2956
2957// insertMCPResourceCompletion inserts the selected resource into the textarea,
2958// replacing the @query, and adds the resource as an attachment.
2959func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2960 displayText := cmp.Or(item.Title, item.URI)
2961
2962 prevHeight := m.textarea.Height()
2963 if !m.insertCompletionText(displayText) {
2964 return nil
2965 }
2966 heightCmd := m.handleTextareaHeightChange(prevHeight)
2967
2968 resourceCmd := func() tea.Msg {
2969 contents, err := m.com.Workspace.ReadMCPResource(
2970 context.Background(),
2971 item.MCPName,
2972 item.URI,
2973 )
2974 if err != nil {
2975 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2976 return nil
2977 }
2978 if len(contents) == 0 {
2979 return nil
2980 }
2981
2982 content := contents[0]
2983 var data []byte
2984 if content.Text != "" {
2985 data = []byte(content.Text)
2986 } else if len(content.Blob) > 0 {
2987 data = content.Blob
2988 }
2989 if len(data) == 0 {
2990 return nil
2991 }
2992
2993 mimeType := item.MIMEType
2994 if mimeType == "" && content.MIMEType != "" {
2995 mimeType = content.MIMEType
2996 }
2997 if mimeType == "" {
2998 mimeType = "text/plain"
2999 }
3000
3001 return message.Attachment{
3002 FilePath: item.URI,
3003 FileName: displayText,
3004 MimeType: mimeType,
3005 Content: data,
3006 }
3007 }
3008 return tea.Batch(heightCmd, resourceCmd)
3009}
3010
3011// completionsPosition returns the X and Y position for the completions popup.
3012func (m *UI) completionsPosition() image.Point {
3013 cur := m.textarea.Cursor()
3014 if cur == nil {
3015 return image.Point{
3016 X: m.layout.editor.Min.X,
3017 Y: m.layout.editor.Min.Y,
3018 }
3019 }
3020 return image.Point{
3021 X: cur.X + m.layout.editor.Min.X,
3022 Y: m.layout.editor.Min.Y + cur.Y,
3023 }
3024}
3025
3026// textareaWord returns the current word at the cursor position.
3027func (m *UI) textareaWord() string {
3028 return m.textarea.Word()
3029}
3030
3031// isWhitespace returns true if the byte is a whitespace character.
3032func isWhitespace(b byte) bool {
3033 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
3034}
3035
3036// isAgentBusy returns true if the agent coordinator exists and is currently
3037// busy processing a request.
3038func (m *UI) isAgentBusy() bool {
3039 return m.com.Workspace.AgentIsReady() &&
3040 m.com.Workspace.AgentIsBusy()
3041}
3042
3043// hasSession returns true if there is an active session with a valid ID.
3044func (m *UI) hasSession() bool {
3045 return m.session != nil && m.session.ID != ""
3046}
3047
3048// mimeOf detects the MIME type of the given content.
3049func mimeOf(content []byte) string {
3050 mimeBufferSize := min(512, len(content))
3051 return http.DetectContentType(content[:mimeBufferSize])
3052}
3053
3054var readyPlaceholders = [...]string{
3055 "Ready!",
3056 "Ready...",
3057 "Ready?",
3058 "Ready for instructions",
3059}
3060
3061var workingPlaceholders = [...]string{
3062 "Working!",
3063 "Working...",
3064 "Brrrrr...",
3065 "Prrrrrrrr...",
3066 "Processing...",
3067 "Thinking...",
3068}
3069
3070// randomizePlaceholders selects random placeholder text for the textarea's
3071// ready and working states.
3072func (m *UI) randomizePlaceholders() {
3073 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
3074 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
3075}
3076
3077// renderEditorView renders the editor view with attachments if any.
3078func (m *UI) renderEditorView(width int) string {
3079 var attachmentsView string
3080 if len(m.attachments.List()) > 0 {
3081 attachmentsView = m.attachments.Render(width)
3082 }
3083 return strings.Join([]string{
3084 attachmentsView,
3085 m.textarea.View(),
3086 "", // margin at bottom of editor
3087 }, "\n")
3088}
3089
3090// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
3091func (m *UI) cacheSidebarLogo(width int) {
3092 m.sidebarLogo = renderLogo(m.com.Styles, true, m.com.IsHyper(), width)
3093}
3094
3095// applyTheme replaces the active styles with the given theme, drops the
3096// shared markdown renderer cache, and refreshes every component that
3097// caches style data.
3098func (m *UI) applyTheme(s styles.Styles) {
3099 *m.com.Styles = s
3100 common.InvalidateMarkdownRendererCache()
3101 m.refreshStyles()
3102}
3103
3104// refreshStyles pushes the current *m.com.Styles into every subcomponent
3105// that copies or pre-renders style-dependent values at construction time.
3106func (m *UI) refreshStyles() {
3107 t := m.com.Styles
3108 m.header.refresh()
3109 if m.layout.sidebar.Dx() > 0 {
3110 m.cacheSidebarLogo(m.layout.sidebar.Dx())
3111 }
3112 m.textarea.SetStyles(t.Editor.Textarea)
3113 m.completions.SetStyles(t.Completions.Normal, t.Completions.Focused, t.Completions.Match)
3114 m.attachments.Renderer().SetStyles(
3115 t.Attachments.Normal,
3116 t.Attachments.Deleting,
3117 t.Attachments.Image,
3118 t.Attachments.Text,
3119 )
3120 m.todoSpinner.Style = t.Pills.TodoSpinner
3121 m.status.help.Styles = t.Help
3122 m.chat.InvalidateRenderCaches()
3123}
3124
3125// sendMessage sends a message with the given content and attachments.
3126func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3127 if !m.com.Workspace.AgentIsReady() {
3128 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
3129 }
3130
3131 var cmds []tea.Cmd
3132 if !m.hasSession() {
3133 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3134 if err != nil {
3135 return util.ReportError(err)
3136 }
3137 if m.forceCompactMode {
3138 m.isCompact = true
3139 }
3140 if newSession.ID != "" {
3141 m.session = &newSession
3142 cmds = append(cmds, m.loadSession(newSession.ID))
3143 }
3144 m.setState(uiChat, m.focus)
3145 }
3146
3147 ctx := context.Background()
3148 cmds = append(cmds, func() tea.Msg {
3149 for _, path := range m.sessionFileReads {
3150 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
3151 m.com.Workspace.LSPStart(ctx, path)
3152 }
3153 return nil
3154 })
3155
3156 // Capture session ID to avoid race with main goroutine updating m.session.
3157 sessionID := m.session.ID
3158 cmds = append(cmds, func() tea.Msg {
3159 err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
3160 if err != nil {
3161 isCancelErr := errors.Is(err, context.Canceled)
3162 if isCancelErr {
3163 return nil
3164 }
3165 return util.InfoMsg{
3166 Type: util.InfoTypeError,
3167 Msg: fmt.Sprintf("%v", err),
3168 }
3169 }
3170 return nil
3171 })
3172 return tea.Batch(cmds...)
3173}
3174
3175const cancelTimerDuration = 2 * time.Second
3176
3177// cancelTimerCmd creates a command that expires the cancel timer.
3178func cancelTimerCmd() tea.Cmd {
3179 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
3180 return cancelTimerExpiredMsg{}
3181 })
3182}
3183
3184// cancelAgent handles the cancel key press. The first press sets isCanceling to true
3185// and starts a timer. The second press (before the timer expires) actually
3186// cancels the agent.
3187func (m *UI) cancelAgent() tea.Cmd {
3188 if !m.hasSession() {
3189 return nil
3190 }
3191
3192 if !m.com.Workspace.AgentIsReady() {
3193 return nil
3194 }
3195
3196 if m.isCanceling {
3197 // Second escape press - actually cancel the agent.
3198 m.isCanceling = false
3199 m.com.Workspace.AgentCancel(m.session.ID)
3200 // Stop the spinning todo indicator.
3201 m.todoIsSpinning = false
3202 m.renderPills()
3203 return nil
3204 }
3205
3206 // Check if there are queued prompts - if so, clear the queue.
3207 if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3208 m.com.Workspace.AgentClearQueue(m.session.ID)
3209 return nil
3210 }
3211
3212 // First escape press - set canceling state and start timer.
3213 m.isCanceling = true
3214 return cancelTimerCmd()
3215}
3216
3217// openDialog opens a dialog by its ID.
3218func (m *UI) openDialog(id string) tea.Cmd {
3219 var cmds []tea.Cmd
3220 switch id {
3221 case dialog.SessionsID:
3222 if cmd := m.openSessionsDialog(); cmd != nil {
3223 cmds = append(cmds, cmd)
3224 }
3225 case dialog.ModelsID:
3226 if cmd := m.openModelsDialog(); cmd != nil {
3227 cmds = append(cmds, cmd)
3228 }
3229 case dialog.CommandsID:
3230 if cmd := m.openCommandsDialog(); cmd != nil {
3231 cmds = append(cmds, cmd)
3232 }
3233 case dialog.ReasoningID:
3234 if cmd := m.openReasoningDialog(); cmd != nil {
3235 cmds = append(cmds, cmd)
3236 }
3237 case dialog.FilePickerID:
3238 if cmd := m.openFilesDialog(); cmd != nil {
3239 cmds = append(cmds, cmd)
3240 }
3241 case dialog.QuitID:
3242 if cmd := m.openQuitDialog(); cmd != nil {
3243 cmds = append(cmds, cmd)
3244 }
3245 default:
3246 // Unknown dialog
3247 break
3248 }
3249 return tea.Batch(cmds...)
3250}
3251
3252// openQuitDialog opens the quit confirmation dialog.
3253func (m *UI) openQuitDialog() tea.Cmd {
3254 if m.dialog.ContainsDialog(dialog.QuitID) {
3255 // Bring to front
3256 m.dialog.BringToFront(dialog.QuitID)
3257 return nil
3258 }
3259
3260 quitDialog := dialog.NewQuit(m.com)
3261 m.dialog.OpenDialog(quitDialog)
3262 return nil
3263}
3264
3265// openModelsDialog opens the models dialog.
3266func (m *UI) openModelsDialog() tea.Cmd {
3267 if m.dialog.ContainsDialog(dialog.ModelsID) {
3268 // Bring to front
3269 m.dialog.BringToFront(dialog.ModelsID)
3270 return nil
3271 }
3272
3273 isOnboarding := m.state == uiOnboarding
3274 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3275 if err != nil {
3276 return util.ReportError(err)
3277 }
3278
3279 m.dialog.OpenDialog(modelsDialog)
3280
3281 return nil
3282}
3283
3284// openCommandsDialog opens the commands dialog.
3285func (m *UI) openCommandsDialog() tea.Cmd {
3286 if m.dialog.ContainsDialog(dialog.CommandsID) {
3287 // Bring to front
3288 m.dialog.BringToFront(dialog.CommandsID)
3289 return nil
3290 }
3291
3292 var sessionID string
3293 hasSession := m.session != nil
3294 if hasSession {
3295 sessionID = m.session.ID
3296 }
3297 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3298 hasQueue := m.promptQueue > 0
3299
3300 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3301 if err != nil {
3302 return util.ReportError(err)
3303 }
3304
3305 m.dialog.OpenDialog(commands)
3306
3307 return commands.InitialCmd()
3308}
3309
3310// openReasoningDialog opens the reasoning effort dialog.
3311func (m *UI) openReasoningDialog() tea.Cmd {
3312 if m.dialog.ContainsDialog(dialog.ReasoningID) {
3313 m.dialog.BringToFront(dialog.ReasoningID)
3314 return nil
3315 }
3316
3317 reasoningDialog, err := dialog.NewReasoning(m.com)
3318 if err != nil {
3319 return util.ReportError(err)
3320 }
3321
3322 m.dialog.OpenDialog(reasoningDialog)
3323 return nil
3324}
3325
3326// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3327// it brings it to the front. Otherwise, it will list all the sessions and open
3328// the dialog.
3329func (m *UI) openSessionsDialog() tea.Cmd {
3330 if m.dialog.ContainsDialog(dialog.SessionsID) {
3331 // Bring to front
3332 m.dialog.BringToFront(dialog.SessionsID)
3333 return nil
3334 }
3335
3336 selectedSessionID := ""
3337 if m.session != nil {
3338 selectedSessionID = m.session.ID
3339 }
3340
3341 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3342 if err != nil {
3343 return util.ReportError(err)
3344 }
3345
3346 m.dialog.OpenDialog(dialog)
3347 return nil
3348}
3349
3350// openFilesDialog opens the file picker dialog.
3351func (m *UI) openFilesDialog() tea.Cmd {
3352 if m.dialog.ContainsDialog(dialog.FilePickerID) {
3353 // Bring to front
3354 m.dialog.BringToFront(dialog.FilePickerID)
3355 return nil
3356 }
3357
3358 filePicker, cmd := dialog.NewFilePicker(m.com)
3359 filePicker.SetImageCapabilities(&m.caps)
3360 m.dialog.OpenDialog(filePicker)
3361
3362 return cmd
3363}
3364
3365// openPermissionsDialog opens the permissions dialog for a permission request.
3366func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3367 // Close any existing permissions dialog first.
3368 m.dialog.CloseDialog(dialog.PermissionsID)
3369
3370 // Get diff mode from config.
3371 var opts []dialog.PermissionsOption
3372 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3373 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3374 }
3375
3376 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3377 m.dialog.OpenDialog(permDialog)
3378 return nil
3379}
3380
3381// handlePermissionNotification updates tool items when permission state changes.
3382func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3383 toolItem := m.chat.MessageItem(notification.ToolCallID)
3384 if toolItem == nil {
3385 return
3386 }
3387
3388 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3389 if notification.Granted {
3390 permItem.SetStatus(chat.ToolStatusRunning)
3391 } else {
3392 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3393 }
3394 }
3395}
3396
3397// handleAgentNotification translates domain agent events into desktop
3398// notifications using the UI notification backend.
3399func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3400 switch n.Type {
3401 case notify.TypeAgentFinished:
3402 var cmds []tea.Cmd
3403 cmds = append(cmds, m.sendNotification(notification.Notification{
3404 Title: "Crush is waiting...",
3405 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3406 }))
3407 if m.com.IsHyper() {
3408 cmds = append(cmds, m.fetchHyperCredits())
3409 }
3410 return tea.Batch(cmds...)
3411 case notify.TypeReAuthenticate:
3412 return m.handleReAuthenticate(n.ProviderID)
3413 default:
3414 return nil
3415 }
3416}
3417
3418func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
3419 cfg := m.com.Config()
3420 if cfg == nil {
3421 return nil
3422 }
3423 providerCfg, ok := cfg.Providers.Get(providerID)
3424 if !ok {
3425 return nil
3426 }
3427 agentCfg, ok := cfg.Agents[config.AgentCoder]
3428 if !ok {
3429 return nil
3430 }
3431 return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
3432}
3433
3434// newSession clears the current session state and prepares for a new session.
3435// The actual session creation happens when the user sends their first message.
3436// Returns a command to reload prompt history.
3437func (m *UI) newSession() tea.Cmd {
3438 if !m.hasSession() {
3439 return nil
3440 }
3441
3442 m.session = nil
3443 m.sessionFiles = nil
3444 m.sessionFileReads = nil
3445 m.setState(uiLanding, uiFocusEditor)
3446 m.textarea.Focus()
3447 m.chat.Blur()
3448 m.chat.ClearMessages()
3449 m.pillsExpanded = false
3450 m.promptQueue = 0
3451 m.pillsView = ""
3452 m.historyReset()
3453 agenttools.ResetCache()
3454 return tea.Batch(
3455 func() tea.Msg {
3456 m.com.Workspace.LSPStopAll(context.Background())
3457 return nil
3458 },
3459 m.loadPromptHistory(),
3460 )
3461}
3462
3463// handlePasteMsg handles a paste message.
3464func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3465 if m.dialog.HasDialogs() {
3466 return m.handleDialogMsg(msg)
3467 }
3468
3469 if m.focus != uiFocusEditor {
3470 return nil
3471 }
3472
3473 if hasPasteExceededThreshold(msg) {
3474 return func() tea.Msg {
3475 content := []byte(msg.Content)
3476 if int64(len(content)) > common.MaxAttachmentSize {
3477 return util.ReportWarn("Paste is too big (>5mb)")
3478 }
3479 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3480 mimeBufferSize := min(512, len(content))
3481 mimeType := http.DetectContentType(content[:mimeBufferSize])
3482 return message.Attachment{
3483 FileName: name,
3484 FilePath: name,
3485 MimeType: mimeType,
3486 Content: content,
3487 }
3488 }
3489 }
3490
3491 // Attempt to parse pasted content as file paths. If possible to parse,
3492 // all files exist and are valid, add as attachments.
3493 // Otherwise, paste as text.
3494 paths := fsext.ParsePastedFiles(msg.Content)
3495 allExistsAndValid := func() bool {
3496 if len(paths) == 0 {
3497 return false
3498 }
3499 for _, path := range paths {
3500 if _, err := os.Stat(path); os.IsNotExist(err) {
3501 return false
3502 }
3503
3504 lowerPath := strings.ToLower(path)
3505 isValid := false
3506 for _, ext := range common.AllowedImageTypes {
3507 if strings.HasSuffix(lowerPath, ext) {
3508 isValid = true
3509 break
3510 }
3511 }
3512 if !isValid {
3513 return false
3514 }
3515 }
3516 return true
3517 }
3518 if !allExistsAndValid() {
3519 prevHeight := m.textarea.Height()
3520 return m.updateTextareaWithPrevHeight(msg, prevHeight)
3521 }
3522
3523 var cmds []tea.Cmd
3524 for _, path := range paths {
3525 cmds = append(cmds, m.handleFilePathPaste(path))
3526 }
3527 return tea.Batch(cmds...)
3528}
3529
3530func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3531 var (
3532 lineCount = 0
3533 colCount = 0
3534 )
3535 for line := range strings.SplitSeq(msg.Content, "\n") {
3536 lineCount++
3537 colCount = max(colCount, len(line))
3538
3539 if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3540 return true
3541 }
3542 }
3543 return false
3544}
3545
3546// handleFilePathPaste handles a pasted file path.
3547func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3548 return func() tea.Msg {
3549 fileInfo, err := os.Stat(path)
3550 if err != nil {
3551 return util.ReportError(err)
3552 }
3553 if fileInfo.IsDir() {
3554 return util.ReportWarn("Cannot attach a directory")
3555 }
3556 if fileInfo.Size() > common.MaxAttachmentSize {
3557 return util.ReportWarn("File is too big (>5mb)")
3558 }
3559
3560 content, err := os.ReadFile(path)
3561 if err != nil {
3562 return util.ReportError(err)
3563 }
3564
3565 mimeBufferSize := min(512, len(content))
3566 mimeType := http.DetectContentType(content[:mimeBufferSize])
3567 fileName := filepath.Base(path)
3568 return message.Attachment{
3569 FilePath: path,
3570 FileName: fileName,
3571 MimeType: mimeType,
3572 Content: content,
3573 }
3574 }
3575}
3576
3577// pasteImageFromClipboard reads image data from the system clipboard and
3578// creates an attachment. If no image data is found, it falls back to
3579// interpreting clipboard text as a file path.
3580func (m *UI) pasteImageFromClipboard() tea.Msg {
3581 imageData, err := readClipboard(clipboardFormatImage)
3582 if int64(len(imageData)) > common.MaxAttachmentSize {
3583 return util.InfoMsg{
3584 Type: util.InfoTypeError,
3585 Msg: "File too large, max 5MB",
3586 }
3587 }
3588 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3589 if err == nil {
3590 return message.Attachment{
3591 FilePath: name,
3592 FileName: name,
3593 MimeType: mimeOf(imageData),
3594 Content: imageData,
3595 }
3596 }
3597
3598 textData, textErr := readClipboard(clipboardFormatText)
3599 if textErr != nil || len(textData) == 0 {
3600 return nil // Clipboard is empty or does not contain an image
3601 }
3602
3603 path := strings.TrimSpace(string(textData))
3604 path = strings.ReplaceAll(path, "\\ ", " ")
3605 if _, statErr := os.Stat(path); statErr != nil {
3606 return nil // Clipboard does not contain an image or valid file path
3607 }
3608
3609 lowerPath := strings.ToLower(path)
3610 isAllowed := false
3611 for _, ext := range common.AllowedImageTypes {
3612 if strings.HasSuffix(lowerPath, ext) {
3613 isAllowed = true
3614 break
3615 }
3616 }
3617 if !isAllowed {
3618 return util.NewInfoMsg("File type is not a supported image format")
3619 }
3620
3621 fileInfo, statErr := os.Stat(path)
3622 if statErr != nil {
3623 return util.InfoMsg{
3624 Type: util.InfoTypeError,
3625 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3626 }
3627 }
3628 if fileInfo.Size() > common.MaxAttachmentSize {
3629 return util.InfoMsg{
3630 Type: util.InfoTypeError,
3631 Msg: "File too large, max 5MB",
3632 }
3633 }
3634
3635 content, readErr := os.ReadFile(path)
3636 if readErr != nil {
3637 return util.InfoMsg{
3638 Type: util.InfoTypeError,
3639 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3640 }
3641 }
3642
3643 return message.Attachment{
3644 FilePath: path,
3645 FileName: filepath.Base(path),
3646 MimeType: mimeOf(content),
3647 Content: content,
3648 }
3649}
3650
3651var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3652
3653func (m *UI) pasteIdx() int {
3654 result := 0
3655 for _, at := range m.attachments.List() {
3656 found := pasteRE.FindStringSubmatch(at.FileName)
3657 if len(found) == 0 {
3658 continue
3659 }
3660 idx, err := strconv.Atoi(found[1])
3661 if err == nil {
3662 result = max(result, idx)
3663 }
3664 }
3665 return result + 1
3666}
3667
3668// drawSessionDetails draws the session details in compact mode.
3669func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3670 if m.session == nil {
3671 return
3672 }
3673
3674 s := m.com.Styles
3675
3676 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3677 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3678
3679 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3680 blocks := []string{
3681 title,
3682 "",
3683 m.modelInfo(width),
3684 "",
3685 }
3686
3687 detailsHeader := lipgloss.JoinVertical(
3688 lipgloss.Left,
3689 blocks...,
3690 )
3691
3692 version := s.CompactDetails.Version.Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3693
3694 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3695
3696 const maxSectionWidth = 50
3697 sectionWidth := max(1, min(maxSectionWidth, width/4-2)) // account for spacing between sections
3698 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3699
3700 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3701 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3702 skillsSection := m.skillsInfo(sectionWidth, maxItemsPerSection, false)
3703 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3704 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection, " ", skillsSection)
3705 uv.NewStyledString(
3706 s.CompactDetails.View.
3707 Width(area.Dx()).
3708 Render(
3709 lipgloss.JoinVertical(
3710 lipgloss.Left,
3711 detailsHeader,
3712 sections,
3713 version,
3714 ),
3715 ),
3716 ).Draw(scr, area)
3717}
3718
3719func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3720 load := func() tea.Msg {
3721 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3722 if err != nil {
3723 // TODO: make this better
3724 return util.ReportError(err)()
3725 }
3726
3727 if prompt == "" {
3728 return nil
3729 }
3730 return sendMessageMsg{
3731 Content: prompt,
3732 }
3733 }
3734
3735 var cmds []tea.Cmd
3736 if cmd := m.dialog.StartLoading(); cmd != nil {
3737 cmds = append(cmds, cmd)
3738 }
3739 cmds = append(cmds, load, func() tea.Msg {
3740 return closeDialogMsg{}
3741 })
3742
3743 return tea.Sequence(cmds...)
3744}
3745
3746func (m *UI) handleStateChanged() tea.Cmd {
3747 return func() tea.Msg {
3748 m.com.Workspace.UpdateAgentModel(context.Background())
3749 return mcpStateChangedMsg{
3750 states: m.com.Workspace.MCPGetStates(),
3751 }
3752 }
3753}
3754
3755func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3756 return func() tea.Msg {
3757 ws.MCPRefreshPrompts(context.Background(), name)
3758 return nil
3759 }
3760}
3761
3762func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3763 return func() tea.Msg {
3764 ws.RefreshMCPTools(context.Background(), name)
3765 return nil
3766 }
3767}
3768
3769func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3770 return func() tea.Msg {
3771 ws.MCPRefreshResources(context.Background(), name)
3772 return nil
3773 }
3774}
3775
3776func (m *UI) copyChatHighlight() tea.Cmd {
3777 text := m.chat.HighlightContent()
3778 return common.CopyToClipboardWithCallback(
3779 text,
3780 "Selected text copied to clipboard",
3781 func() tea.Msg {
3782 m.chat.ClearMouse()
3783 return nil
3784 },
3785 )
3786}
3787
3788func (m *UI) enableDockerMCP() tea.Msg {
3789 ctx := context.Background()
3790 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3791 return util.ReportError(err)()
3792 }
3793
3794 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3795}
3796
3797func (m *UI) disableDockerMCP() tea.Msg {
3798 if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3799 return util.ReportError(err)()
3800 }
3801
3802 return util.NewInfoMsg("Docker MCP disabled successfully")
3803}
3804
3805// renderLogo renders the Crush logo with the given styles and dimensions.
3806func renderLogo(t *styles.Styles, compact, hyper bool, width int) string {
3807 return logo.Render(t.Logo.GradCanvas, version.Version, compact, logo.Opts{
3808 FieldColor: t.Logo.FieldColor,
3809 TitleColorA: t.Logo.TitleColorA,
3810 TitleColorB: t.Logo.TitleColorB,
3811 CharmColor: t.Logo.CharmColor,
3812 VersionColor: t.Logo.VersionColor,
3813 Width: width,
3814 Hyper: hyper,
3815 })
3816}