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