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