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