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