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