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