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