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