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