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 case key.Matches(msg, m.keyMap.ToggleYolo):
1809 yolo := !m.com.Workspace.PermissionSkipRequests()
1810 m.com.Workspace.PermissionSetSkipRequests(yolo)
1811 m.setEditorPrompt(yolo)
1812 return true
1813 }
1814 return false
1815 }
1816
1817 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1818 // Always handle quit keys first
1819 if cmd := m.openQuitDialog(); cmd != nil {
1820 cmds = append(cmds, cmd)
1821 }
1822
1823 return tea.Batch(cmds...)
1824 }
1825
1826 // Route all messages to dialog if one is open.
1827 if m.dialog.HasDialogs() {
1828 return m.handleDialogMsg(msg)
1829 }
1830
1831 // Handle cancel key when agent is busy.
1832 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1833 if m.isAgentBusy() {
1834 if cmd := m.cancelAgent(); cmd != nil {
1835 cmds = append(cmds, cmd)
1836 }
1837 return tea.Batch(cmds...)
1838 }
1839 }
1840
1841 switch m.state {
1842 case uiOnboarding:
1843 return tea.Batch(cmds...)
1844 case uiInitialize:
1845 cmds = append(cmds, m.updateInitializeView(msg)...)
1846 return tea.Batch(cmds...)
1847 case uiChat, uiLanding:
1848 switch m.focus {
1849 case uiFocusEditor:
1850 // Handle completions if open.
1851 if m.completionsOpen {
1852 if msg, ok := m.completions.Update(msg); ok {
1853 switch msg := msg.(type) {
1854 case completions.SelectionMsg[completions.FileCompletionValue]:
1855 cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1856 if !msg.KeepOpen {
1857 m.closeCompletions()
1858 }
1859 case completions.SelectionMsg[completions.ResourceCompletionValue]:
1860 cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1861 if !msg.KeepOpen {
1862 m.closeCompletions()
1863 }
1864 case completions.ClosedMsg:
1865 m.completionsOpen = false
1866 }
1867 return tea.Batch(cmds...)
1868 }
1869 }
1870
1871 if ok := m.attachments.Update(msg); ok {
1872 return tea.Batch(cmds...)
1873 }
1874
1875 switch {
1876 case key.Matches(msg, m.keyMap.Editor.AddImage):
1877 if !m.currentModelSupportsImages() {
1878 break
1879 }
1880 if cmd := m.openFilesDialog(); cmd != nil {
1881 cmds = append(cmds, cmd)
1882 }
1883
1884 case key.Matches(msg, m.keyMap.Editor.PasteImage):
1885 if !m.currentModelSupportsImages() {
1886 break
1887 }
1888 cmds = append(cmds, m.pasteImageFromClipboard)
1889
1890 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1891 prevHeight := m.textarea.Height()
1892 value := m.textarea.Value()
1893 if before, ok := strings.CutSuffix(value, "\\"); ok {
1894 // If the last character is a backslash, remove it and add a newline.
1895 m.textarea.SetValue(before)
1896 if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1897 cmds = append(cmds, cmd)
1898 }
1899 break
1900 }
1901
1902 // Otherwise, send the message
1903 m.textarea.Reset()
1904 if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
1905 cmds = append(cmds, cmd)
1906 }
1907
1908 value = strings.TrimSpace(value)
1909 if value == "exit" || value == "quit" {
1910 return m.openQuitDialog()
1911 }
1912
1913 attachments := m.attachments.List()
1914 m.attachments.Reset()
1915 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1916 return nil
1917 }
1918
1919 m.randomizePlaceholders()
1920 m.historyReset()
1921
1922 return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1923 case key.Matches(msg, m.keyMap.Chat.NewSession):
1924 if !m.hasSession() {
1925 break
1926 }
1927 if m.isAgentBusy() {
1928 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1929 break
1930 }
1931 if cmd := m.newSession(); cmd != nil {
1932 cmds = append(cmds, cmd)
1933 }
1934 case key.Matches(msg, m.keyMap.Tab):
1935 if m.state != uiLanding {
1936 m.setState(m.state, uiFocusMain)
1937 m.textarea.Blur()
1938 m.chat.Focus()
1939 m.chat.SetSelected(m.chat.Len() - 1)
1940 }
1941 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1942 if m.isAgentBusy() {
1943 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1944 break
1945 }
1946 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1947 case key.Matches(msg, m.keyMap.Editor.Newline):
1948 prevHeight := m.textarea.Height()
1949 m.textarea.InsertRune('\n')
1950 m.closeCompletions()
1951 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
1952 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1953 cmd := m.handleHistoryUp(msg)
1954 if cmd != nil {
1955 cmds = append(cmds, cmd)
1956 }
1957 case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1958 cmd := m.handleHistoryDown(msg)
1959 if cmd != nil {
1960 cmds = append(cmds, cmd)
1961 }
1962 case key.Matches(msg, m.keyMap.Editor.Escape):
1963 cmd := m.handleHistoryEscape(msg)
1964 if cmd != nil {
1965 cmds = append(cmds, cmd)
1966 }
1967 case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1968 if cmd := m.openCommandsDialog(); cmd != nil {
1969 cmds = append(cmds, cmd)
1970 }
1971 default:
1972 if handleGlobalKeys(msg) {
1973 // Handle global keys first before passing to textarea.
1974 break
1975 }
1976
1977 // Check for @ trigger before passing to textarea.
1978 curValue := m.textarea.Value()
1979 curIdx := len(curValue)
1980
1981 // Trigger completions on @.
1982 if msg.String() == "@" && !m.completionsOpen {
1983 // Only show if beginning of prompt or after whitespace.
1984 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1985 m.completionsOpen = true
1986 m.completionsQuery = ""
1987 m.completionsStartIndex = curIdx
1988 m.completionsPositionStart = m.completionsPosition()
1989 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1990 cmds = append(cmds, m.completions.Open(depth, limit))
1991 }
1992 }
1993
1994 // remove the details if they are open when user starts typing
1995 if m.detailsOpen {
1996 m.detailsOpen = false
1997 m.updateLayoutAndSize()
1998 }
1999
2000 prevHeight := m.textarea.Height()
2001 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
2002
2003 // Any text modification becomes the current draft.
2004 m.updateHistoryDraft(curValue)
2005
2006 // After updating textarea, check if we need to filter completions.
2007 // Skip filtering on the initial @ keystroke since items are loading async.
2008 if m.completionsOpen && msg.String() != "@" {
2009 newValue := m.textarea.Value()
2010 newIdx := len(newValue)
2011
2012 // Close completions if cursor moved before start.
2013 if newIdx <= m.completionsStartIndex {
2014 m.closeCompletions()
2015 } else if msg.String() == "space" {
2016 // Close on space.
2017 m.closeCompletions()
2018 } else {
2019 // Extract current word and filter.
2020 word := m.textareaWord()
2021 if strings.HasPrefix(word, "@") {
2022 m.completionsQuery = word[1:]
2023 m.completions.Filter(m.completionsQuery)
2024 } else if m.completionsOpen {
2025 m.closeCompletions()
2026 }
2027 }
2028 }
2029 }
2030 case uiFocusMain:
2031 switch {
2032 case key.Matches(msg, m.keyMap.Tab):
2033 m.focus = uiFocusEditor
2034 cmds = append(cmds, m.textarea.Focus())
2035 m.chat.Blur()
2036 case key.Matches(msg, m.keyMap.Chat.NewSession):
2037 if !m.hasSession() {
2038 break
2039 }
2040 if m.isAgentBusy() {
2041 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
2042 break
2043 }
2044 m.focus = uiFocusEditor
2045 if cmd := m.newSession(); cmd != nil {
2046 cmds = append(cmds, cmd)
2047 }
2048 case key.Matches(msg, m.keyMap.Chat.Expand):
2049 m.chat.ToggleExpandedSelectedItem()
2050 case key.Matches(msg, m.keyMap.Chat.Up):
2051 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
2052 cmds = append(cmds, cmd)
2053 }
2054 if !m.chat.SelectedItemInView() {
2055 m.chat.SelectPrev()
2056 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2057 cmds = append(cmds, cmd)
2058 }
2059 }
2060 case key.Matches(msg, m.keyMap.Chat.Down):
2061 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
2062 cmds = append(cmds, cmd)
2063 }
2064 if !m.chat.SelectedItemInView() {
2065 m.chat.SelectNext()
2066 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2067 cmds = append(cmds, cmd)
2068 }
2069 }
2070 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
2071 m.chat.SelectPrev()
2072 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2073 cmds = append(cmds, cmd)
2074 }
2075 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
2076 m.chat.SelectNext()
2077 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
2078 cmds = append(cmds, cmd)
2079 }
2080 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
2081 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
2082 cmds = append(cmds, cmd)
2083 }
2084 m.chat.SelectFirstInView()
2085 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
2086 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
2087 cmds = append(cmds, cmd)
2088 }
2089 m.chat.SelectLastInView()
2090 case key.Matches(msg, m.keyMap.Chat.PageUp):
2091 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
2092 cmds = append(cmds, cmd)
2093 }
2094 m.chat.SelectFirstInView()
2095 case key.Matches(msg, m.keyMap.Chat.PageDown):
2096 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
2097 cmds = append(cmds, cmd)
2098 }
2099 m.chat.SelectLastInView()
2100 case key.Matches(msg, m.keyMap.Chat.Home):
2101 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
2102 cmds = append(cmds, cmd)
2103 }
2104 m.chat.SelectFirst()
2105 case key.Matches(msg, m.keyMap.Chat.End):
2106 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
2107 cmds = append(cmds, cmd)
2108 }
2109 m.chat.SelectLast()
2110 default:
2111 if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
2112 cmds = append(cmds, cmd)
2113 } else {
2114 handleGlobalKeys(msg)
2115 }
2116 }
2117 default:
2118 handleGlobalKeys(msg)
2119 }
2120 default:
2121 handleGlobalKeys(msg)
2122 }
2123
2124 return tea.Sequence(cmds...)
2125}
2126
2127// drawHeader draws the header section of the UI.
2128func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
2129 m.header.drawHeader(
2130 scr,
2131 area,
2132 m.session,
2133 m.isCompact,
2134 m.detailsOpen,
2135 area.Dx(),
2136 m.hyperCredits,
2137 )
2138}
2139
2140// Draw implements [uv.Drawable] and draws the UI model.
2141func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
2142 layout := m.generateLayout(area.Dx(), area.Dy())
2143
2144 if m.layout != layout {
2145 m.layout = layout
2146 m.updateSize()
2147 }
2148
2149 // Clear the screen first
2150 screen.Clear(scr)
2151
2152 switch m.state {
2153 case uiOnboarding:
2154 m.drawHeader(scr, layout.header)
2155
2156 // NOTE: Onboarding flow will be rendered as dialogs below, but
2157 // positioned at the bottom left of the screen.
2158
2159 case uiInitialize:
2160 m.drawHeader(scr, layout.header)
2161
2162 main := uv.NewStyledString(m.initializeView())
2163 main.Draw(scr, layout.main)
2164
2165 case uiLanding:
2166 m.drawHeader(scr, layout.header)
2167 main := uv.NewStyledString(m.landingView())
2168 main.Draw(scr, layout.main)
2169
2170 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
2171 editor.Draw(scr, layout.editor)
2172
2173 case uiChat:
2174 if m.isCompact {
2175 m.drawHeader(scr, layout.header)
2176 } else {
2177 m.drawSidebar(scr, layout.sidebar)
2178 }
2179
2180 m.chat.Draw(scr, layout.main)
2181 if layout.pills.Dy() > 0 && m.pillsView != "" {
2182 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
2183 }
2184
2185 editorWidth := scr.Bounds().Dx()
2186 if !m.isCompact {
2187 editorWidth -= layout.sidebar.Dx()
2188 }
2189 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
2190 editor.Draw(scr, layout.editor)
2191
2192 // Draw details overlay in compact mode when open
2193 if m.isCompact && m.detailsOpen {
2194 m.drawSessionDetails(scr, layout.sessionDetails)
2195 }
2196 }
2197
2198 isOnboarding := m.state == uiOnboarding
2199
2200 // Add status and help layer
2201 m.status.SetHideHelp(isOnboarding)
2202 m.status.Draw(scr, layout.status)
2203
2204 // Draw completions popup if open
2205 if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
2206 w, h := m.completions.Size()
2207 x := m.completionsPositionStart.X
2208 y := m.completionsPositionStart.Y - h
2209
2210 screenW := area.Dx()
2211 if x+w > screenW {
2212 x = screenW - w
2213 }
2214 x = max(0, x)
2215 y = max(0, y+1) // Offset for attachments row
2216
2217 completionsView := uv.NewStyledString(m.completions.Render())
2218 completionsView.Draw(scr, image.Rectangle{
2219 Min: image.Pt(x, y),
2220 Max: image.Pt(x+w, y+h),
2221 })
2222 }
2223
2224 // Debugging rendering (visually see when the tui rerenders)
2225 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2226 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2227 debug := uv.NewStyledString(debugView.String())
2228 debug.Draw(scr, image.Rectangle{
2229 Min: image.Pt(4, 1),
2230 Max: image.Pt(8, 3),
2231 })
2232 }
2233
2234 // This needs to come last to overlay on top of everything. We always pass
2235 // the full screen bounds because the dialogs will position themselves
2236 // accordingly.
2237 if m.dialog.HasDialogs() {
2238 return m.dialog.Draw(scr, scr.Bounds())
2239 }
2240
2241 switch m.focus {
2242 case uiFocusEditor:
2243 if m.layout.editor.Dy() <= 0 {
2244 // Don't show cursor if editor is not visible
2245 return nil
2246 }
2247 if m.detailsOpen && m.isCompact {
2248 // Don't show cursor if details overlay is open
2249 return nil
2250 }
2251
2252 if m.textarea.Focused() {
2253 cur := m.textarea.Cursor()
2254 cur.X++ // Adjust for app margins
2255 cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2256 return cur
2257 }
2258 }
2259 return nil
2260}
2261
2262// View renders the UI model's view.
2263func (m *UI) View() tea.View {
2264 var v tea.View
2265 v.AltScreen = true
2266 if !m.isTransparent {
2267 v.BackgroundColor = m.com.Styles.Background
2268 }
2269 v.MouseMode = tea.MouseModeCellMotion
2270 v.ReportFocus = m.caps.ReportFocusEvents
2271 v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2272
2273 canvas := uv.NewScreenBuffer(m.width, m.height)
2274 v.Cursor = m.Draw(canvas, canvas.Bounds())
2275
2276 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2277 contentLines := strings.Split(content, "\n")
2278 for i, line := range contentLines {
2279 // Trim trailing spaces for concise rendering
2280 contentLines[i] = strings.TrimRight(line, " ")
2281 }
2282
2283 content = strings.Join(contentLines, "\n")
2284
2285 v.Content = content
2286 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2287 // HACK: use a random percentage to prevent ghostty from hiding it
2288 // after a timeout.
2289 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2290 }
2291
2292 return v
2293}
2294
2295// ShortHelp implements [help.KeyMap].
2296func (m *UI) ShortHelp() []key.Binding {
2297 var binds []key.Binding
2298 k := &m.keyMap
2299 tab := k.Tab
2300 commands := k.Commands
2301 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2302 commands.SetHelp("/ or ctrl+p", "commands")
2303 }
2304
2305 switch m.state {
2306 case uiInitialize:
2307 binds = append(binds, k.Quit)
2308 case uiChat:
2309 // Show cancel binding if agent is busy.
2310 if m.isAgentBusy() {
2311 cancelBinding := k.Chat.Cancel
2312 if m.isCanceling {
2313 cancelBinding.SetHelp("esc", "press again to cancel")
2314 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2315 cancelBinding.SetHelp("esc", "clear queue")
2316 }
2317 binds = append(binds, cancelBinding)
2318 }
2319
2320 if m.focus == uiFocusEditor {
2321 tab.SetHelp("tab", "focus chat")
2322 } else {
2323 tab.SetHelp("tab", "focus editor")
2324 }
2325
2326 binds = append(
2327 binds,
2328 tab,
2329 commands,
2330 k.Models,
2331 )
2332
2333 switch m.focus {
2334 case uiFocusEditor:
2335 binds = append(
2336 binds,
2337 k.Editor.Newline,
2338 )
2339 case uiFocusMain:
2340 binds = append(
2341 binds,
2342 k.Chat.UpDown,
2343 k.Chat.UpDownOneItem,
2344 k.Chat.PageUp,
2345 k.Chat.PageDown,
2346 k.Chat.Copy,
2347 )
2348 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2349 binds = append(binds, k.Chat.PillLeft)
2350 }
2351 }
2352 default:
2353 // TODO: other states
2354 // if m.session == nil {
2355 // no session selected
2356 binds = append(
2357 binds,
2358 commands,
2359 k.Models,
2360 k.Editor.Newline,
2361 )
2362 }
2363
2364 binds = append(
2365 binds,
2366 k.Quit,
2367 k.Help,
2368 )
2369
2370 return binds
2371}
2372
2373// FullHelp implements [help.KeyMap].
2374func (m *UI) FullHelp() [][]key.Binding {
2375 var binds [][]key.Binding
2376 k := &m.keyMap
2377 help := k.Help
2378 help.SetHelp("ctrl+g", "less")
2379 hasAttachments := len(m.attachments.List()) > 0
2380 hasSession := m.hasSession()
2381 commands := k.Commands
2382 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2383 commands.SetHelp("/ or ctrl+p", "commands")
2384 }
2385
2386 switch m.state {
2387 case uiInitialize:
2388 binds = append(binds,
2389 []key.Binding{
2390 k.Quit,
2391 })
2392 case uiChat:
2393 // Show cancel binding if agent is busy.
2394 if m.isAgentBusy() {
2395 cancelBinding := k.Chat.Cancel
2396 if m.isCanceling {
2397 cancelBinding.SetHelp("esc", "press again to cancel")
2398 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2399 cancelBinding.SetHelp("esc", "clear queue")
2400 }
2401 binds = append(binds, []key.Binding{cancelBinding})
2402 }
2403
2404 mainBinds := []key.Binding{}
2405 tab := k.Tab
2406 if m.focus == uiFocusEditor {
2407 tab.SetHelp("tab", "focus chat")
2408 } else {
2409 tab.SetHelp("tab", "focus editor")
2410 }
2411
2412 mainBinds = append(
2413 mainBinds,
2414 tab,
2415 commands,
2416 k.Models,
2417 k.Sessions,
2418 k.ToggleYolo,
2419 )
2420 if hasSession {
2421 mainBinds = append(mainBinds, k.Chat.NewSession)
2422 }
2423
2424 binds = append(binds, mainBinds)
2425
2426 switch m.focus {
2427 case uiFocusEditor:
2428 editorBinds := []key.Binding{
2429 k.Editor.Newline,
2430 k.Editor.MentionFile,
2431 k.Editor.OpenEditor,
2432 }
2433 if m.currentModelSupportsImages() {
2434 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2435 }
2436 binds = append(binds, editorBinds)
2437 if hasAttachments {
2438 binds = append(
2439 binds,
2440 []key.Binding{
2441 k.Editor.AttachmentDeleteMode,
2442 k.Editor.DeleteAllAttachments,
2443 k.Editor.Escape,
2444 },
2445 )
2446 }
2447 case uiFocusMain:
2448 binds = append(
2449 binds,
2450 []key.Binding{
2451 k.Chat.UpDown,
2452 k.Chat.UpDownOneItem,
2453 k.Chat.PageUp,
2454 k.Chat.PageDown,
2455 },
2456 []key.Binding{
2457 k.Chat.HalfPageUp,
2458 k.Chat.HalfPageDown,
2459 k.Chat.Home,
2460 k.Chat.End,
2461 },
2462 []key.Binding{
2463 k.Chat.Copy,
2464 k.Chat.ClearHighlight,
2465 },
2466 )
2467 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2468 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2469 }
2470 }
2471 default:
2472 if m.session == nil {
2473 // no session selected
2474 binds = append(
2475 binds,
2476 []key.Binding{
2477 commands,
2478 k.Models,
2479 k.Sessions,
2480 k.ToggleYolo,
2481 },
2482 )
2483 editorBinds := []key.Binding{
2484 k.Editor.Newline,
2485 k.Editor.MentionFile,
2486 k.Editor.OpenEditor,
2487 }
2488 if m.currentModelSupportsImages() {
2489 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2490 }
2491 binds = append(binds, editorBinds)
2492 if hasAttachments {
2493 binds = append(
2494 binds,
2495 []key.Binding{
2496 k.Editor.AttachmentDeleteMode,
2497 k.Editor.DeleteAllAttachments,
2498 k.Editor.Escape,
2499 },
2500 )
2501 }
2502 }
2503 }
2504
2505 binds = append(
2506 binds,
2507 []key.Binding{
2508 help,
2509 k.Quit,
2510 },
2511 )
2512
2513 return binds
2514}
2515
2516func (m *UI) currentModelSupportsImages() bool {
2517 cfg := m.com.Config()
2518 if cfg == nil {
2519 return false
2520 }
2521 agentCfg, ok := cfg.Agents[config.AgentCoder]
2522 if !ok {
2523 return false
2524 }
2525 model := cfg.GetModelByType(agentCfg.Model)
2526 return model != nil && model.SupportsImages
2527}
2528
2529// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2530func (m *UI) toggleCompactMode() tea.Cmd {
2531 m.forceCompactMode = !m.forceCompactMode
2532
2533 err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2534 if err != nil {
2535 return util.ReportError(err)
2536 }
2537
2538 m.updateLayoutAndSize()
2539
2540 return nil
2541}
2542
2543// updateLayoutAndSize updates the layout and sizes of UI components.
2544func (m *UI) updateLayoutAndSize() {
2545 // Determine if we should be in compact mode
2546 if m.state == uiChat {
2547 if m.forceCompactMode {
2548 m.isCompact = true
2549 } else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2550 m.isCompact = true
2551 } else {
2552 m.isCompact = false
2553 }
2554 }
2555
2556 // First pass sizes components from the current textarea height.
2557 m.layout = m.generateLayout(m.width, m.height)
2558 prevHeight := m.textarea.Height()
2559 m.updateSize()
2560
2561 // SetWidth can change textarea height due to soft-wrap recalculation.
2562 // If that happens, run one reconciliation pass with the new height.
2563 if m.textarea.Height() != prevHeight {
2564 m.layout = m.generateLayout(m.width, m.height)
2565 m.updateSize()
2566 }
2567}
2568
2569// handleTextareaHeightChange checks whether the textarea height changed and,
2570// if so, recalculates the layout. When the chat is in follow mode it keeps
2571// the view scrolled to the bottom. The returned command, if non-nil, must be
2572// batched by the caller.
2573func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
2574 if m.textarea.Height() == prevHeight {
2575 return nil
2576 }
2577 m.updateLayoutAndSize()
2578 if m.state == uiChat && m.chat.Follow() {
2579 return m.chat.ScrollToBottomAndAnimate()
2580 }
2581 return nil
2582}
2583
2584// updateTextarea updates the textarea for msg and then reconciles layout if
2585// the textarea height changed as a result.
2586func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
2587 return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
2588}
2589
2590// updateTextareaWithPrevHeight is for cases when the height of the layout may
2591// have changed.
2592//
2593// Particularly, it's for cases where the textarea changes before
2594// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
2595// pass the height from before those changes took place so we can compare
2596// "before" vs "after" sizing and recalculate the layout if the textarea grew
2597// or shrank.
2598func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2599 ta, cmd := m.textarea.Update(msg)
2600 m.textarea = ta
2601 return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
2602}
2603
2604// updateSize updates the sizes of UI components based on the current layout.
2605func (m *UI) updateSize() {
2606 // Set status width
2607 m.status.SetWidth(m.layout.status.Dx())
2608
2609 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2610 m.textarea.MaxHeight = TextareaMaxHeight
2611 m.textarea.SetWidth(m.layout.editor.Dx())
2612 m.renderPills()
2613
2614 // Handle different app states
2615 switch m.state {
2616 case uiChat:
2617 if !m.isCompact {
2618 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2619 }
2620 }
2621}
2622
2623// generateLayout calculates the layout rectangles for all UI components based
2624// on the current UI state and terminal dimensions.
2625func (m *UI) generateLayout(w, h int) uiLayout {
2626 // The screen area we're working with
2627 area := image.Rect(0, 0, w, h)
2628
2629 // The help height
2630 helpHeight := 1
2631 // The editor height: textarea height + margin for attachments and bottom spacing.
2632 editorHeight := m.textarea.Height() + editorHeightMargin
2633 // The sidebar width
2634 sidebarWidth := 30
2635 // The header height
2636 const landingHeaderHeight = 4
2637
2638 var helpKeyMap help.KeyMap = m
2639 if m.status != nil && m.status.ShowingAll() {
2640 for _, row := range helpKeyMap.FullHelp() {
2641 helpHeight = max(helpHeight, len(row))
2642 }
2643 }
2644
2645 // Add app margins
2646 var appRect, helpRect image.Rectangle
2647 layout.Vertical(
2648 layout.Len(area.Dy()-helpHeight),
2649 layout.Fill(1),
2650 ).Split(area).Assign(&appRect, &helpRect)
2651 appRect.Min.Y += 1
2652 appRect.Max.Y -= 1
2653 helpRect.Min.Y -= 1
2654 appRect.Min.X += 1
2655 appRect.Max.X -= 1
2656
2657 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2658 // extra padding on left and right for these states
2659 appRect.Min.X += 1
2660 appRect.Max.X -= 1
2661 }
2662
2663 uiLayout := uiLayout{
2664 area: area,
2665 status: helpRect,
2666 }
2667
2668 // Handle different app states
2669 switch m.state {
2670 case uiOnboarding, uiInitialize:
2671 // Layout
2672 //
2673 // header
2674 // ------
2675 // main
2676 // ------
2677 // help
2678
2679 var headerRect, mainRect image.Rectangle
2680 layout.Vertical(
2681 layout.Len(landingHeaderHeight),
2682 layout.Fill(1),
2683 ).Split(appRect).Assign(&headerRect, &mainRect)
2684 uiLayout.header = headerRect
2685 uiLayout.main = mainRect
2686
2687 case uiLanding:
2688 // Layout
2689 //
2690 // header
2691 // ------
2692 // main
2693 // ------
2694 // editor
2695 // ------
2696 // help
2697 var headerRect, mainRect image.Rectangle
2698 layout.Vertical(
2699 layout.Len(landingHeaderHeight),
2700 layout.Fill(1),
2701 ).Split(appRect).Assign(&headerRect, &mainRect)
2702 var editorRect image.Rectangle
2703 layout.Vertical(
2704 layout.Len(mainRect.Dy()-editorHeight),
2705 layout.Fill(1),
2706 ).Split(mainRect).Assign(&mainRect, &editorRect)
2707 // Remove extra padding from editor (but keep it for header and main)
2708 editorRect.Min.X -= 1
2709 editorRect.Max.X += 1
2710 uiLayout.header = headerRect
2711 uiLayout.main = mainRect
2712 uiLayout.editor = editorRect
2713
2714 case uiChat:
2715 if m.isCompact {
2716 // Layout
2717 //
2718 // compact-header
2719 // ------
2720 // main
2721 // ------
2722 // editor
2723 // ------
2724 // help
2725 const compactHeaderHeight = 1
2726 var headerRect, mainRect image.Rectangle
2727 layout.Vertical(
2728 layout.Len(compactHeaderHeight),
2729 layout.Fill(1),
2730 ).Split(appRect).Assign(&headerRect, &mainRect)
2731 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2732 var sessionDetailsArea image.Rectangle
2733 layout.Vertical(
2734 layout.Len(detailsHeight),
2735 layout.Fill(1),
2736 ).Split(appRect).Assign(&sessionDetailsArea, new(image.Rectangle))
2737 uiLayout.sessionDetails = sessionDetailsArea
2738 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2739 // Add one line gap between header and main content
2740 mainRect.Min.Y += 1
2741 var editorRect image.Rectangle
2742 layout.Vertical(
2743 layout.Len(mainRect.Dy()-editorHeight),
2744 layout.Fill(1),
2745 ).Split(mainRect).Assign(&mainRect, &editorRect)
2746 mainRect.Max.X -= 1 // Add padding right
2747 uiLayout.header = headerRect
2748 pillsHeight := m.pillsAreaHeight()
2749 if pillsHeight > 0 {
2750 pillsHeight = min(pillsHeight, mainRect.Dy())
2751 var chatRect, pillsRect image.Rectangle
2752 layout.Vertical(
2753 layout.Len(mainRect.Dy()-pillsHeight),
2754 layout.Fill(1),
2755 ).Split(mainRect).Assign(&chatRect, &pillsRect)
2756 uiLayout.main = chatRect
2757 uiLayout.pills = pillsRect
2758 } else {
2759 uiLayout.main = mainRect
2760 }
2761 // Add bottom margin to main
2762 uiLayout.main.Max.Y -= 1
2763 uiLayout.editor = editorRect
2764 } else {
2765 // Layout
2766 //
2767 // ------|---
2768 // main |
2769 // ------| side
2770 // editor|
2771 // ----------
2772 // help
2773
2774 var mainRect, sideRect image.Rectangle
2775 layout.Horizontal(
2776 layout.Len(appRect.Dx()-sidebarWidth),
2777 layout.Fill(1),
2778 ).Split(appRect).Assign(&mainRect, &sideRect)
2779 // Add padding left
2780 sideRect.Min.X += 1
2781 var editorRect image.Rectangle
2782 layout.Vertical(
2783 layout.Len(mainRect.Dy()-editorHeight),
2784 layout.Fill(1),
2785 ).Split(mainRect).Assign(&mainRect, &editorRect)
2786 mainRect.Max.X -= 1 // Add padding right
2787 uiLayout.sidebar = sideRect
2788 pillsHeight := m.pillsAreaHeight()
2789 if pillsHeight > 0 {
2790 pillsHeight = min(pillsHeight, mainRect.Dy())
2791 var chatRect, pillsRect image.Rectangle
2792 layout.Vertical(
2793 layout.Len(mainRect.Dy()-pillsHeight),
2794 layout.Fill(1),
2795 ).Split(mainRect).Assign(&chatRect, &pillsRect)
2796 uiLayout.main = chatRect
2797 uiLayout.pills = pillsRect
2798 } else {
2799 uiLayout.main = mainRect
2800 }
2801 // Add bottom margin to main
2802 uiLayout.main.Max.Y -= 1
2803 uiLayout.editor = editorRect
2804 }
2805 }
2806
2807 return uiLayout
2808}
2809
2810// uiLayout defines the positioning of UI elements.
2811type uiLayout struct {
2812 // area is the overall available area.
2813 area uv.Rectangle
2814
2815 // header is the header shown in special cases
2816 // e.x when the sidebar is collapsed
2817 // or when in the landing page
2818 // or in init/config
2819 header uv.Rectangle
2820
2821 // main is the area for the main pane. (e.x chat, configure, landing)
2822 main uv.Rectangle
2823
2824 // pills is the area for the pills panel.
2825 pills uv.Rectangle
2826
2827 // editor is the area for the editor pane.
2828 editor uv.Rectangle
2829
2830 // sidebar is the area for the sidebar.
2831 sidebar uv.Rectangle
2832
2833 // status is the area for the status view.
2834 status uv.Rectangle
2835
2836 // session details is the area for the session details overlay in compact mode.
2837 sessionDetails uv.Rectangle
2838}
2839
2840func (m *UI) openEditor(value string) tea.Cmd {
2841 tmpfile, err := os.CreateTemp("", "msg_*.md")
2842 if err != nil {
2843 return util.ReportError(err)
2844 }
2845 tmpPath := tmpfile.Name()
2846 defer tmpfile.Close() //nolint:errcheck
2847 if _, err := tmpfile.WriteString(value); err != nil {
2848 return util.ReportError(err)
2849 }
2850 cmd, err := editor.Command(
2851 "crush",
2852 tmpPath,
2853 editor.AtPosition(
2854 m.textarea.Line()+1,
2855 m.textarea.Column()+1,
2856 ),
2857 )
2858 if err != nil {
2859 return util.ReportError(err)
2860 }
2861 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2862 defer func() {
2863 _ = os.Remove(tmpPath)
2864 }()
2865
2866 if err != nil {
2867 return util.ReportError(err)
2868 }
2869 content, err := os.ReadFile(tmpPath)
2870 if err != nil {
2871 return util.ReportError(err)
2872 }
2873 if len(content) == 0 {
2874 return util.ReportWarn("Message is empty")
2875 }
2876 return openEditorMsg{
2877 Text: strings.TrimSpace(string(content)),
2878 }
2879 })
2880}
2881
2882// setEditorPrompt configures the textarea prompt function based on whether
2883// yolo mode is enabled.
2884func (m *UI) setEditorPrompt(yolo bool) {
2885 if yolo {
2886 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2887 return
2888 }
2889 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2890}
2891
2892// normalPromptFunc returns the normal editor prompt style (" > " on first
2893// line, "::: " on subsequent lines).
2894func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2895 t := m.com.Styles
2896 if info.LineNumber == 0 {
2897 if info.Focused {
2898 return " > "
2899 }
2900 return "::: "
2901 }
2902 if info.Focused {
2903 return t.Editor.PromptNormalFocused.Render()
2904 }
2905 return t.Editor.PromptNormalBlurred.Render()
2906}
2907
2908// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2909// and colored dots.
2910func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2911 t := m.com.Styles
2912 if info.LineNumber == 0 {
2913 if info.Focused {
2914 return t.Editor.PromptYoloIconFocused.Render()
2915 } else {
2916 return t.Editor.PromptYoloIconBlurred.Render()
2917 }
2918 }
2919 if info.Focused {
2920 return t.Editor.PromptYoloDotsFocused.Render()
2921 }
2922 return t.Editor.PromptYoloDotsBlurred.Render()
2923}
2924
2925// closeCompletions closes the completions popup and resets state.
2926func (m *UI) closeCompletions() {
2927 m.completionsOpen = false
2928 m.completionsQuery = ""
2929 m.completionsStartIndex = 0
2930 m.completions.Close()
2931}
2932
2933// insertCompletionText replaces the @query in the textarea with the given text.
2934// Returns false if the replacement cannot be performed.
2935func (m *UI) insertCompletionText(text string) bool {
2936 value := m.textarea.Value()
2937 if m.completionsStartIndex > len(value) {
2938 return false
2939 }
2940
2941 word := m.textareaWord()
2942 endIdx := min(m.completionsStartIndex+len(word), len(value))
2943 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2944 m.textarea.SetValue(newValue)
2945 m.textarea.MoveToEnd()
2946 m.textarea.InsertRune(' ')
2947 return true
2948}
2949
2950// insertFileCompletion inserts the selected file path into the textarea,
2951// replacing the @query, and adds the file as an attachment.
2952func (m *UI) insertFileCompletion(path string) tea.Cmd {
2953 prevHeight := m.textarea.Height()
2954 if !m.insertCompletionText(path) {
2955 return nil
2956 }
2957 heightCmd := m.handleTextareaHeightChange(prevHeight)
2958
2959 fileCmd := func() tea.Msg {
2960 absPath, _ := filepath.Abs(path)
2961
2962 if m.hasSession() {
2963 // Skip attachment if file was already read and hasn't been modified.
2964 lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2965 if !lastRead.IsZero() {
2966 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2967 return nil
2968 }
2969 }
2970 } else if slices.Contains(m.sessionFileReads, absPath) {
2971 return nil
2972 }
2973
2974 m.sessionFileReads = append(m.sessionFileReads, absPath)
2975
2976 // Add file as attachment.
2977 content, err := os.ReadFile(path)
2978 if err != nil {
2979 // If it fails, let the LLM handle it later.
2980 return nil
2981 }
2982
2983 return message.Attachment{
2984 FilePath: path,
2985 FileName: filepath.Base(path),
2986 MimeType: mimeOf(content),
2987 Content: content,
2988 }
2989 }
2990 return tea.Batch(heightCmd, fileCmd)
2991}
2992
2993// insertMCPResourceCompletion inserts the selected resource into the textarea,
2994// replacing the @query, and adds the resource as an attachment.
2995func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2996 displayText := cmp.Or(item.Title, item.URI)
2997
2998 prevHeight := m.textarea.Height()
2999 if !m.insertCompletionText(displayText) {
3000 return nil
3001 }
3002 heightCmd := m.handleTextareaHeightChange(prevHeight)
3003
3004 resourceCmd := func() tea.Msg {
3005 contents, err := m.com.Workspace.ReadMCPResource(
3006 context.Background(),
3007 item.MCPName,
3008 item.URI,
3009 )
3010 if err != nil {
3011 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
3012 return nil
3013 }
3014 if len(contents) == 0 {
3015 return nil
3016 }
3017
3018 content := contents[0]
3019 var data []byte
3020 if content.Text != "" {
3021 data = []byte(content.Text)
3022 } else if len(content.Blob) > 0 {
3023 data = content.Blob
3024 }
3025 if len(data) == 0 {
3026 return nil
3027 }
3028
3029 mimeType := item.MIMEType
3030 if mimeType == "" && content.MIMEType != "" {
3031 mimeType = content.MIMEType
3032 }
3033 if mimeType == "" {
3034 mimeType = "text/plain"
3035 }
3036
3037 return message.Attachment{
3038 FilePath: item.URI,
3039 FileName: displayText,
3040 MimeType: mimeType,
3041 Content: data,
3042 }
3043 }
3044 return tea.Batch(heightCmd, resourceCmd)
3045}
3046
3047// completionsPosition returns the X and Y position for the completions popup.
3048func (m *UI) completionsPosition() image.Point {
3049 cur := m.textarea.Cursor()
3050 if cur == nil {
3051 return image.Point{
3052 X: m.layout.editor.Min.X,
3053 Y: m.layout.editor.Min.Y,
3054 }
3055 }
3056 return image.Point{
3057 X: cur.X + m.layout.editor.Min.X,
3058 Y: m.layout.editor.Min.Y + cur.Y,
3059 }
3060}
3061
3062// textareaWord returns the current word at the cursor position.
3063func (m *UI) textareaWord() string {
3064 return m.textarea.Word()
3065}
3066
3067// isWhitespace returns true if the byte is a whitespace character.
3068func isWhitespace(b byte) bool {
3069 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
3070}
3071
3072// isAgentBusy returns true if the agent coordinator exists and is currently
3073// busy processing a request.
3074func (m *UI) isAgentBusy() bool {
3075 return m.com.Workspace.AgentIsReady() &&
3076 m.com.Workspace.AgentIsBusy()
3077}
3078
3079// hasSession returns true if there is an active session with a valid ID.
3080func (m *UI) hasSession() bool {
3081 return m.session != nil && m.session.ID != ""
3082}
3083
3084// mimeOf detects the MIME type of the given content.
3085func mimeOf(content []byte) string {
3086 mimeBufferSize := min(512, len(content))
3087 return http.DetectContentType(content[:mimeBufferSize])
3088}
3089
3090var readyPlaceholders = [...]string{
3091 "Ready!",
3092 "Ready...",
3093 "Ready?",
3094 "Ready for instructions",
3095}
3096
3097var workingPlaceholders = [...]string{
3098 "Working!",
3099 "Working...",
3100 "Brrrrr...",
3101 "Prrrrrrrr...",
3102 "Processing...",
3103 "Thinking...",
3104}
3105
3106// randomizePlaceholders selects random placeholder text for the textarea's
3107// ready and working states.
3108func (m *UI) randomizePlaceholders() {
3109 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
3110 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
3111}
3112
3113// renderEditorView renders the editor view with attachments if any.
3114func (m *UI) renderEditorView(width int) string {
3115 var attachmentsView string
3116 if len(m.attachments.List()) > 0 {
3117 attachmentsView = m.attachments.Render(width)
3118 }
3119 return strings.Join([]string{
3120 attachmentsView,
3121 m.textarea.View(),
3122 "", // margin at bottom of editor
3123 }, "\n")
3124}
3125
3126// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
3127func (m *UI) cacheSidebarLogo(width int) {
3128 m.sidebarLogo = renderLogo(m.com.Styles, true, m.com.IsHyper(), width)
3129}
3130
3131// applyTheme replaces the active styles with the given theme, drops the
3132// shared markdown renderer cache, and refreshes every component that
3133// caches style data.
3134func (m *UI) applyTheme(s styles.Styles) {
3135 *m.com.Styles = s
3136 common.InvalidateMarkdownRendererCache()
3137 m.refreshStyles()
3138}
3139
3140// refreshStyles pushes the current *m.com.Styles into every subcomponent
3141// that copies or pre-renders style-dependent values at construction time.
3142func (m *UI) refreshStyles() {
3143 t := m.com.Styles
3144 m.header.refresh()
3145 if m.layout.sidebar.Dx() > 0 {
3146 m.cacheSidebarLogo(m.layout.sidebar.Dx())
3147 }
3148 m.textarea.SetStyles(t.Editor.Textarea)
3149 m.completions.SetStyles(t.Completions.Normal, t.Completions.Focused, t.Completions.Match)
3150 m.attachments.Renderer().SetStyles(
3151 t.Attachments.Normal,
3152 t.Attachments.Deleting,
3153 t.Attachments.Image,
3154 t.Attachments.Text,
3155 t.Attachments.Skill,
3156 )
3157 m.todoSpinner.Style = t.Pills.TodoSpinner
3158 m.status.help.Styles = t.Help
3159 m.chat.InvalidateRenderCaches()
3160}
3161
3162// attachSkill reads a skill's content by ID and returns it as a markdown
3163// attachment to be added to the attachment toolbar. The user can then
3164// compose a message and send it with the skill attached.
3165// The name parameter is used as a fallback when the server does not
3166// return one.
3167func (m *UI) attachSkill(skillID, name string) tea.Cmd {
3168 return func() tea.Msg {
3169 content, result, err := m.com.Workspace.ReadSkill(context.Background(), skillID)
3170 if err != nil {
3171 return util.NewErrorMsg(err)
3172 }
3173 fileName := result.Name
3174 if fileName == "" {
3175 fileName = name
3176 }
3177 return message.Attachment{
3178 FilePath: fileName,
3179 FileName: fileName,
3180 MimeType: "text/markdown",
3181 Content: content,
3182 }
3183 }
3184}
3185
3186// sendMessage sends a message with the given content and attachments.
3187func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3188 if !m.com.Workspace.AgentIsReady() {
3189 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
3190 }
3191
3192 var cmds []tea.Cmd
3193 if !m.hasSession() {
3194 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3195 if err != nil {
3196 return util.ReportError(err)
3197 }
3198 if m.forceCompactMode {
3199 m.isCompact = true
3200 }
3201 if newSession.ID != "" {
3202 m.session = &newSession
3203 cmds = append(cmds, m.loadSession(newSession.ID))
3204 }
3205 m.setState(uiChat, m.focus)
3206 }
3207
3208 ctx := context.Background()
3209 cmds = append(cmds, func() tea.Msg {
3210 for _, path := range m.sessionFileReads {
3211 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
3212 m.com.Workspace.LSPStart(ctx, path)
3213 }
3214 return nil
3215 })
3216
3217 // Capture session ID to avoid race with main goroutine updating m.session.
3218 sessionID := m.session.ID
3219 cmds = append(cmds, func() tea.Msg {
3220 err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
3221 if err != nil {
3222 isCancelErr := errors.Is(err, context.Canceled)
3223 if isCancelErr {
3224 return nil
3225 }
3226 return util.InfoMsg{
3227 Type: util.InfoTypeError,
3228 Msg: fmt.Sprintf("%v", err),
3229 }
3230 }
3231 return nil
3232 })
3233 return tea.Batch(cmds...)
3234}
3235
3236const cancelTimerDuration = 2 * time.Second
3237
3238// cancelTimerCmd creates a command that expires the cancel timer.
3239func cancelTimerCmd() tea.Cmd {
3240 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
3241 return cancelTimerExpiredMsg{}
3242 })
3243}
3244
3245// cancelAgent handles the cancel key press. The first press sets isCanceling to true
3246// and starts a timer. The second press (before the timer expires) actually
3247// cancels the agent.
3248func (m *UI) cancelAgent() tea.Cmd {
3249 if !m.hasSession() {
3250 return nil
3251 }
3252
3253 if !m.com.Workspace.AgentIsReady() {
3254 return nil
3255 }
3256
3257 if m.isCanceling {
3258 // Second escape press - actually cancel the agent.
3259 m.isCanceling = false
3260 m.com.Workspace.AgentCancel(m.session.ID)
3261 // Stop the spinning todo indicator.
3262 m.todoIsSpinning = false
3263 m.renderPills()
3264 return nil
3265 }
3266
3267 // Check if there are queued prompts - if so, clear the queue.
3268 if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3269 m.com.Workspace.AgentClearQueue(m.session.ID)
3270 return nil
3271 }
3272
3273 // First escape press - set canceling state and start timer.
3274 m.isCanceling = true
3275 return cancelTimerCmd()
3276}
3277
3278// openDialog opens a dialog by its ID.
3279func (m *UI) openDialog(id string) tea.Cmd {
3280 var cmds []tea.Cmd
3281 switch id {
3282 case dialog.SessionsID:
3283 if cmd := m.openSessionsDialog(); cmd != nil {
3284 cmds = append(cmds, cmd)
3285 }
3286 case dialog.ModelsID:
3287 if cmd := m.openModelsDialog(); cmd != nil {
3288 cmds = append(cmds, cmd)
3289 }
3290 case dialog.CommandsID:
3291 if cmd := m.openCommandsDialog(); cmd != nil {
3292 cmds = append(cmds, cmd)
3293 }
3294 case dialog.ReasoningID:
3295 if cmd := m.openReasoningDialog(); cmd != nil {
3296 cmds = append(cmds, cmd)
3297 }
3298 case dialog.FilePickerID:
3299 if cmd := m.openFilesDialog(); cmd != nil {
3300 cmds = append(cmds, cmd)
3301 }
3302 case dialog.QuitID:
3303 if cmd := m.openQuitDialog(); cmd != nil {
3304 cmds = append(cmds, cmd)
3305 }
3306 default:
3307 // Unknown dialog
3308 break
3309 }
3310 return tea.Batch(cmds...)
3311}
3312
3313// openQuitDialog opens the quit confirmation dialog.
3314func (m *UI) openQuitDialog() tea.Cmd {
3315 if m.dialog.ContainsDialog(dialog.QuitID) {
3316 // Bring to front
3317 m.dialog.BringToFront(dialog.QuitID)
3318 return nil
3319 }
3320
3321 quitDialog := dialog.NewQuit(m.com)
3322 m.dialog.OpenDialog(quitDialog)
3323 return nil
3324}
3325
3326// openModelsDialog opens the models dialog.
3327func (m *UI) openModelsDialog() tea.Cmd {
3328 if m.dialog.ContainsDialog(dialog.ModelsID) {
3329 // Bring to front
3330 m.dialog.BringToFront(dialog.ModelsID)
3331 return nil
3332 }
3333
3334 isOnboarding := m.state == uiOnboarding
3335 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3336 if err != nil {
3337 return util.ReportError(err)
3338 }
3339
3340 m.dialog.OpenDialog(modelsDialog)
3341
3342 return nil
3343}
3344
3345// openCommandsDialog opens the commands dialog.
3346func (m *UI) openCommandsDialog() tea.Cmd {
3347 if m.dialog.ContainsDialog(dialog.CommandsID) {
3348 // Bring to front
3349 m.dialog.BringToFront(dialog.CommandsID)
3350 return nil
3351 }
3352
3353 var sessionID string
3354 hasSession := m.session != nil
3355 if hasSession {
3356 sessionID = m.session.ID
3357 }
3358 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3359 hasQueue := m.promptQueue > 0
3360
3361 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3362 if err != nil {
3363 return util.ReportError(err)
3364 }
3365
3366 m.dialog.OpenDialog(commands)
3367
3368 return commands.InitialCmd()
3369}
3370
3371// openReasoningDialog opens the reasoning effort dialog.
3372func (m *UI) openReasoningDialog() tea.Cmd {
3373 if m.dialog.ContainsDialog(dialog.ReasoningID) {
3374 m.dialog.BringToFront(dialog.ReasoningID)
3375 return nil
3376 }
3377
3378 reasoningDialog, err := dialog.NewReasoning(m.com)
3379 if err != nil {
3380 return util.ReportError(err)
3381 }
3382
3383 m.dialog.OpenDialog(reasoningDialog)
3384 return nil
3385}
3386
3387// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3388// it brings it to the front. Otherwise, it will list all the sessions and open
3389// the dialog.
3390func (m *UI) openSessionsDialog() tea.Cmd {
3391 if m.dialog.ContainsDialog(dialog.SessionsID) {
3392 // Bring to front
3393 m.dialog.BringToFront(dialog.SessionsID)
3394 return nil
3395 }
3396
3397 selectedSessionID := ""
3398 if m.session != nil {
3399 selectedSessionID = m.session.ID
3400 }
3401
3402 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3403 if err != nil {
3404 return util.ReportError(err)
3405 }
3406
3407 m.dialog.OpenDialog(dialog)
3408 return nil
3409}
3410
3411// openFilesDialog opens the file picker dialog.
3412func (m *UI) openFilesDialog() tea.Cmd {
3413 if m.dialog.ContainsDialog(dialog.FilePickerID) {
3414 // Bring to front
3415 m.dialog.BringToFront(dialog.FilePickerID)
3416 return nil
3417 }
3418
3419 filePicker, cmd := dialog.NewFilePicker(m.com)
3420 filePicker.SetImageCapabilities(&m.caps)
3421 m.dialog.OpenDialog(filePicker)
3422
3423 return cmd
3424}
3425
3426// openPermissionsDialog opens the permissions dialog for a permission request.
3427func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3428 // Close any existing permissions dialog first.
3429 m.dialog.CloseDialog(dialog.PermissionsID)
3430
3431 // Get diff mode from config.
3432 var opts []dialog.PermissionsOption
3433 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3434 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3435 }
3436
3437 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3438 m.dialog.OpenDialog(permDialog)
3439 return nil
3440}
3441
3442// handlePermissionNotification updates tool items when permission state changes.
3443func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3444 toolItem := m.chat.MessageItem(notification.ToolCallID)
3445 if toolItem == nil {
3446 return
3447 }
3448
3449 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3450 if notification.Granted {
3451 permItem.SetStatus(chat.ToolStatusRunning)
3452 } else {
3453 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3454 }
3455 }
3456}
3457
3458// handleAgentNotification translates domain agent events into desktop
3459// notifications using the UI notification backend.
3460func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3461 switch n.Type {
3462 case notify.TypeAgentFinished:
3463 var cmds []tea.Cmd
3464 cmds = append(cmds, m.sendNotification(notification.Notification{
3465 Title: "Crush is waiting...",
3466 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3467 }))
3468 if m.com.IsHyper() {
3469 cmds = append(cmds, m.fetchHyperCredits())
3470 }
3471 return tea.Batch(cmds...)
3472 case notify.TypeReAuthenticate:
3473 return m.handleReAuthenticate(n.ProviderID)
3474 default:
3475 return nil
3476 }
3477}
3478
3479func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
3480 cfg := m.com.Config()
3481 if cfg == nil {
3482 return nil
3483 }
3484 providerCfg, ok := cfg.Providers.Get(providerID)
3485 if !ok {
3486 return nil
3487 }
3488 agentCfg, ok := cfg.Agents[config.AgentCoder]
3489 if !ok {
3490 return nil
3491 }
3492 return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
3493}
3494
3495// newSession clears the current session state and prepares for a new session.
3496// The actual session creation happens when the user sends their first message.
3497// Returns a command to reload prompt history.
3498func (m *UI) newSession() tea.Cmd {
3499 if !m.hasSession() {
3500 return nil
3501 }
3502
3503 m.session = nil
3504 m.sessionFiles = nil
3505 m.sessionFileReads = nil
3506 m.setState(uiLanding, uiFocusEditor)
3507 m.textarea.Focus()
3508 m.chat.Blur()
3509 m.chat.ClearMessages()
3510 m.pillsExpanded = false
3511 m.pillsAutoExpanded = false
3512 m.promptQueue = 0
3513 m.pillsView = ""
3514 m.historyReset()
3515 agenttools.ResetCache()
3516 return tea.Batch(
3517 func() tea.Msg {
3518 m.com.Workspace.LSPStopAll(context.Background())
3519 return nil
3520 },
3521 m.loadPromptHistory(),
3522 )
3523}
3524
3525// handlePasteMsg handles a paste message.
3526func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3527 // Normalize \r\n before the textarea sanitizer sees it.
3528 msg.Content = strings.ReplaceAll(msg.Content, "\r\n", "\n")
3529
3530 if m.dialog.HasDialogs() {
3531 return m.handleDialogMsg(msg)
3532 }
3533
3534 if m.focus != uiFocusEditor {
3535 return nil
3536 }
3537
3538 if hasPasteExceededThreshold(msg) {
3539 return func() tea.Msg {
3540 content := []byte(msg.Content)
3541 if int64(len(content)) > common.MaxAttachmentSize {
3542 return util.ReportWarn("Paste is too big (>5mb)")
3543 }
3544 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3545 mimeBufferSize := min(512, len(content))
3546 mimeType := http.DetectContentType(content[:mimeBufferSize])
3547 return message.Attachment{
3548 FileName: name,
3549 FilePath: name,
3550 MimeType: mimeType,
3551 Content: content,
3552 }
3553 }
3554 }
3555
3556 // Attempt to parse pasted content as file paths. If possible to parse,
3557 // all files exist and are valid, add as attachments.
3558 // Otherwise, paste as text.
3559 paths := fsext.ParsePastedFiles(msg.Content)
3560 allExistsAndValid := func() bool {
3561 if len(paths) == 0 {
3562 return false
3563 }
3564 for _, path := range paths {
3565 if _, err := os.Stat(path); os.IsNotExist(err) {
3566 return false
3567 }
3568
3569 lowerPath := strings.ToLower(path)
3570 isValid := false
3571 for _, ext := range common.AllowedImageTypes {
3572 if strings.HasSuffix(lowerPath, ext) {
3573 isValid = true
3574 break
3575 }
3576 }
3577 if !isValid {
3578 return false
3579 }
3580 }
3581 return true
3582 }
3583 if !allExistsAndValid() {
3584 prevHeight := m.textarea.Height()
3585 return m.updateTextareaWithPrevHeight(msg, prevHeight)
3586 }
3587
3588 var cmds []tea.Cmd
3589 for _, path := range paths {
3590 cmds = append(cmds, m.handleFilePathPaste(path))
3591 }
3592 return tea.Batch(cmds...)
3593}
3594
3595func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3596 var (
3597 lineCount = 0
3598 colCount = 0
3599 )
3600 for line := range strings.SplitSeq(msg.Content, "\n") {
3601 lineCount++
3602 colCount = max(colCount, len(line))
3603
3604 if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3605 return true
3606 }
3607 }
3608 return false
3609}
3610
3611// handleFilePathPaste handles a pasted file path.
3612func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3613 return func() tea.Msg {
3614 fileInfo, err := os.Stat(path)
3615 if err != nil {
3616 return util.ReportError(err)
3617 }
3618 if fileInfo.IsDir() {
3619 return util.ReportWarn("Cannot attach a directory")
3620 }
3621 if fileInfo.Size() > common.MaxAttachmentSize {
3622 return util.ReportWarn("File is too big (>5mb)")
3623 }
3624
3625 content, err := os.ReadFile(path)
3626 if err != nil {
3627 return util.ReportError(err)
3628 }
3629
3630 mimeBufferSize := min(512, len(content))
3631 mimeType := http.DetectContentType(content[:mimeBufferSize])
3632 fileName := filepath.Base(path)
3633 return message.Attachment{
3634 FilePath: path,
3635 FileName: fileName,
3636 MimeType: mimeType,
3637 Content: content,
3638 }
3639 }
3640}
3641
3642// pasteImageFromClipboard reads image data from the system clipboard and
3643// creates an attachment. If no image data is found, it falls back to
3644// interpreting clipboard text as a file path.
3645func (m *UI) pasteImageFromClipboard() tea.Msg {
3646 imageData, err := readClipboard(clipboardFormatImage)
3647 if int64(len(imageData)) > common.MaxAttachmentSize {
3648 return util.InfoMsg{
3649 Type: util.InfoTypeError,
3650 Msg: "File too large, max 5MB",
3651 }
3652 }
3653 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3654 if err == nil {
3655 return message.Attachment{
3656 FilePath: name,
3657 FileName: name,
3658 MimeType: mimeOf(imageData),
3659 Content: imageData,
3660 }
3661 }
3662
3663 textData, textErr := readClipboard(clipboardFormatText)
3664 if textErr != nil || len(textData) == 0 {
3665 return nil // Clipboard is empty or does not contain an image
3666 }
3667
3668 path := strings.TrimSpace(string(textData))
3669 path = strings.ReplaceAll(path, "\\ ", " ")
3670 if _, statErr := os.Stat(path); statErr != nil {
3671 return nil // Clipboard does not contain an image or valid file path
3672 }
3673
3674 lowerPath := strings.ToLower(path)
3675 isAllowed := false
3676 for _, ext := range common.AllowedImageTypes {
3677 if strings.HasSuffix(lowerPath, ext) {
3678 isAllowed = true
3679 break
3680 }
3681 }
3682 if !isAllowed {
3683 return util.NewInfoMsg("File type is not a supported image format")
3684 }
3685
3686 fileInfo, statErr := os.Stat(path)
3687 if statErr != nil {
3688 return util.InfoMsg{
3689 Type: util.InfoTypeError,
3690 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3691 }
3692 }
3693 if fileInfo.Size() > common.MaxAttachmentSize {
3694 return util.InfoMsg{
3695 Type: util.InfoTypeError,
3696 Msg: "File too large, max 5MB",
3697 }
3698 }
3699
3700 content, readErr := os.ReadFile(path)
3701 if readErr != nil {
3702 return util.InfoMsg{
3703 Type: util.InfoTypeError,
3704 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3705 }
3706 }
3707
3708 return message.Attachment{
3709 FilePath: path,
3710 FileName: filepath.Base(path),
3711 MimeType: mimeOf(content),
3712 Content: content,
3713 }
3714}
3715
3716var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3717
3718func (m *UI) pasteIdx() int {
3719 result := 0
3720 for _, at := range m.attachments.List() {
3721 found := pasteRE.FindStringSubmatch(at.FileName)
3722 if len(found) == 0 {
3723 continue
3724 }
3725 idx, err := strconv.Atoi(found[1])
3726 if err == nil {
3727 result = max(result, idx)
3728 }
3729 }
3730 return result + 1
3731}
3732
3733// drawSessionDetails draws the session details in compact mode.
3734func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3735 if m.session == nil {
3736 return
3737 }
3738
3739 s := m.com.Styles
3740
3741 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3742 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3743
3744 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3745 blocks := []string{
3746 title,
3747 "",
3748 m.modelInfo(width),
3749 "",
3750 }
3751
3752 detailsHeader := lipgloss.JoinVertical(
3753 lipgloss.Left,
3754 blocks...,
3755 )
3756
3757 version := s.CompactDetails.Version.Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3758
3759 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3760
3761 const maxSectionWidth = 50
3762 sectionWidth := max(1, min(maxSectionWidth, width/4-2)) // account for spacing between sections
3763 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3764
3765 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3766 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3767 skillsSection := m.skillsInfo(sectionWidth, maxItemsPerSection, false)
3768 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3769 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection, " ", skillsSection)
3770 uv.NewStyledString(
3771 s.CompactDetails.View.
3772 Width(area.Dx()).
3773 Render(
3774 lipgloss.JoinVertical(
3775 lipgloss.Left,
3776 detailsHeader,
3777 sections,
3778 version,
3779 ),
3780 ),
3781 ).Draw(scr, area)
3782}
3783
3784func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3785 load := func() tea.Msg {
3786 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3787 if err != nil {
3788 // TODO: make this better
3789 return util.ReportError(err)()
3790 }
3791
3792 if prompt == "" {
3793 return nil
3794 }
3795 return sendMessageMsg{
3796 Content: prompt,
3797 }
3798 }
3799
3800 var cmds []tea.Cmd
3801 if cmd := m.dialog.StartLoading(); cmd != nil {
3802 cmds = append(cmds, cmd)
3803 }
3804 cmds = append(cmds, load, func() tea.Msg {
3805 return closeDialogMsg{}
3806 })
3807
3808 return tea.Sequence(cmds...)
3809}
3810
3811func (m *UI) handleStateChanged() tea.Cmd {
3812 return func() tea.Msg {
3813 m.com.Workspace.UpdateAgentModel(context.Background())
3814 return mcpStateChangedMsg{
3815 states: m.com.Workspace.MCPGetStates(),
3816 }
3817 }
3818}
3819
3820func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3821 return func() tea.Msg {
3822 ws.MCPRefreshPrompts(context.Background(), name)
3823 return nil
3824 }
3825}
3826
3827func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3828 return func() tea.Msg {
3829 ws.RefreshMCPTools(context.Background(), name)
3830 return nil
3831 }
3832}
3833
3834func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3835 return func() tea.Msg {
3836 ws.MCPRefreshResources(context.Background(), name)
3837 return nil
3838 }
3839}
3840
3841func (m *UI) copyChatHighlight() tea.Cmd {
3842 text := m.chat.HighlightContent()
3843 return common.CopyToClipboardWithCallback(
3844 text,
3845 "Selected text copied to clipboard",
3846 func() tea.Msg {
3847 m.chat.ClearMouse()
3848 return nil
3849 },
3850 )
3851}
3852
3853func (m *UI) enableDockerMCP() tea.Msg {
3854 ctx := context.Background()
3855 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3856 return util.ReportError(err)()
3857 }
3858
3859 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3860}
3861
3862func (m *UI) disableDockerMCP() tea.Msg {
3863 if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3864 return util.ReportError(err)()
3865 }
3866
3867 return util.NewInfoMsg("Docker MCP disabled successfully")
3868}
3869
3870// renderLogo renders the Crush logo with the given styles and dimensions.
3871func renderLogo(t *styles.Styles, compact, hyper bool, width int) string {
3872 return logo.Render(t.Logo.GradCanvas, version.Version, compact, logo.Opts{
3873 FieldColor: t.Logo.FieldColor,
3874 TitleColorA: t.Logo.TitleColorA,
3875 TitleColorB: t.Logo.TitleColorB,
3876 CharmColor: t.Logo.CharmColor,
3877 VersionColor: t.Logo.VersionColor,
3878 Width: width,
3879 Hyper: hyper,
3880 })
3881}