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