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 toolItem := m.chat.MessageItem(notification.ToolCallID)
3396 if toolItem == nil {
3397 return
3398 }
3399
3400 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3401 if notification.Granted {
3402 permItem.SetStatus(chat.ToolStatusRunning)
3403 } else {
3404 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3405 }
3406 }
3407}
3408
3409// handleAgentNotification translates domain agent events into desktop
3410// notifications using the UI notification backend.
3411func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3412 switch n.Type {
3413 case notify.TypeAgentFinished:
3414 var cmds []tea.Cmd
3415 cmds = append(cmds, m.sendNotification(notification.Notification{
3416 Title: "Crush is waiting...",
3417 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3418 }))
3419 if m.com.IsHyper() {
3420 cmds = append(cmds, m.fetchHyperCredits())
3421 }
3422 return tea.Batch(cmds...)
3423 case notify.TypeReAuthenticate:
3424 return m.handleReAuthenticate(n.ProviderID)
3425 default:
3426 return nil
3427 }
3428}
3429
3430func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
3431 cfg := m.com.Config()
3432 if cfg == nil {
3433 return nil
3434 }
3435 providerCfg, ok := cfg.Providers.Get(providerID)
3436 if !ok {
3437 return nil
3438 }
3439 agentCfg, ok := cfg.Agents[config.AgentCoder]
3440 if !ok {
3441 return nil
3442 }
3443 return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
3444}
3445
3446// newSession clears the current session state and prepares for a new session.
3447// The actual session creation happens when the user sends their first message.
3448// Returns a command to reload prompt history.
3449func (m *UI) newSession() tea.Cmd {
3450 if !m.hasSession() {
3451 return nil
3452 }
3453
3454 m.session = nil
3455 m.sessionFiles = nil
3456 m.sessionFileReads = nil
3457 m.setState(uiLanding, uiFocusEditor)
3458 m.textarea.Focus()
3459 m.chat.Blur()
3460 m.chat.ClearMessages()
3461 m.pillsExpanded = false
3462 m.promptQueue = 0
3463 m.pillsView = ""
3464 m.historyReset()
3465 agenttools.ResetCache()
3466 return tea.Batch(
3467 func() tea.Msg {
3468 m.com.Workspace.LSPStopAll(context.Background())
3469 return nil
3470 },
3471 m.loadPromptHistory(),
3472 )
3473}
3474
3475// handlePasteMsg handles a paste message.
3476func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3477 // Normalize \r\n before the textarea sanitizer sees it.
3478 msg.Content = strings.ReplaceAll(msg.Content, "\r\n", "\n")
3479
3480 if m.dialog.HasDialogs() {
3481 return m.handleDialogMsg(msg)
3482 }
3483
3484 if m.focus != uiFocusEditor {
3485 return nil
3486 }
3487
3488 if hasPasteExceededThreshold(msg) {
3489 return func() tea.Msg {
3490 content := []byte(msg.Content)
3491 if int64(len(content)) > common.MaxAttachmentSize {
3492 return util.ReportWarn("Paste is too big (>5mb)")
3493 }
3494 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3495 mimeBufferSize := min(512, len(content))
3496 mimeType := http.DetectContentType(content[:mimeBufferSize])
3497 return message.Attachment{
3498 FileName: name,
3499 FilePath: name,
3500 MimeType: mimeType,
3501 Content: content,
3502 }
3503 }
3504 }
3505
3506 // Attempt to parse pasted content as file paths. If possible to parse,
3507 // all files exist and are valid, add as attachments.
3508 // Otherwise, paste as text.
3509 paths := fsext.ParsePastedFiles(msg.Content)
3510 allExistsAndValid := func() bool {
3511 if len(paths) == 0 {
3512 return false
3513 }
3514 for _, path := range paths {
3515 if _, err := os.Stat(path); os.IsNotExist(err) {
3516 return false
3517 }
3518
3519 lowerPath := strings.ToLower(path)
3520 isValid := false
3521 for _, ext := range common.AllowedImageTypes {
3522 if strings.HasSuffix(lowerPath, ext) {
3523 isValid = true
3524 break
3525 }
3526 }
3527 if !isValid {
3528 return false
3529 }
3530 }
3531 return true
3532 }
3533 if !allExistsAndValid() {
3534 prevHeight := m.textarea.Height()
3535 return m.updateTextareaWithPrevHeight(msg, prevHeight)
3536 }
3537
3538 var cmds []tea.Cmd
3539 for _, path := range paths {
3540 cmds = append(cmds, m.handleFilePathPaste(path))
3541 }
3542 return tea.Batch(cmds...)
3543}
3544
3545func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3546 var (
3547 lineCount = 0
3548 colCount = 0
3549 )
3550 for line := range strings.SplitSeq(msg.Content, "\n") {
3551 lineCount++
3552 colCount = max(colCount, len(line))
3553
3554 if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3555 return true
3556 }
3557 }
3558 return false
3559}
3560
3561// handleFilePathPaste handles a pasted file path.
3562func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3563 return func() tea.Msg {
3564 fileInfo, err := os.Stat(path)
3565 if err != nil {
3566 return util.ReportError(err)
3567 }
3568 if fileInfo.IsDir() {
3569 return util.ReportWarn("Cannot attach a directory")
3570 }
3571 if fileInfo.Size() > common.MaxAttachmentSize {
3572 return util.ReportWarn("File is too big (>5mb)")
3573 }
3574
3575 content, err := os.ReadFile(path)
3576 if err != nil {
3577 return util.ReportError(err)
3578 }
3579
3580 mimeBufferSize := min(512, len(content))
3581 mimeType := http.DetectContentType(content[:mimeBufferSize])
3582 fileName := filepath.Base(path)
3583 return message.Attachment{
3584 FilePath: path,
3585 FileName: fileName,
3586 MimeType: mimeType,
3587 Content: content,
3588 }
3589 }
3590}
3591
3592// pasteImageFromClipboard reads image data from the system clipboard and
3593// creates an attachment. If no image data is found, it falls back to
3594// interpreting clipboard text as a file path.
3595func (m *UI) pasteImageFromClipboard() tea.Msg {
3596 imageData, err := readClipboard(clipboardFormatImage)
3597 if int64(len(imageData)) > common.MaxAttachmentSize {
3598 return util.InfoMsg{
3599 Type: util.InfoTypeError,
3600 Msg: "File too large, max 5MB",
3601 }
3602 }
3603 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3604 if err == nil {
3605 return message.Attachment{
3606 FilePath: name,
3607 FileName: name,
3608 MimeType: mimeOf(imageData),
3609 Content: imageData,
3610 }
3611 }
3612
3613 textData, textErr := readClipboard(clipboardFormatText)
3614 if textErr != nil || len(textData) == 0 {
3615 return nil // Clipboard is empty or does not contain an image
3616 }
3617
3618 path := strings.TrimSpace(string(textData))
3619 path = strings.ReplaceAll(path, "\\ ", " ")
3620 if _, statErr := os.Stat(path); statErr != nil {
3621 return nil // Clipboard does not contain an image or valid file path
3622 }
3623
3624 lowerPath := strings.ToLower(path)
3625 isAllowed := false
3626 for _, ext := range common.AllowedImageTypes {
3627 if strings.HasSuffix(lowerPath, ext) {
3628 isAllowed = true
3629 break
3630 }
3631 }
3632 if !isAllowed {
3633 return util.NewInfoMsg("File type is not a supported image format")
3634 }
3635
3636 fileInfo, statErr := os.Stat(path)
3637 if statErr != nil {
3638 return util.InfoMsg{
3639 Type: util.InfoTypeError,
3640 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3641 }
3642 }
3643 if fileInfo.Size() > common.MaxAttachmentSize {
3644 return util.InfoMsg{
3645 Type: util.InfoTypeError,
3646 Msg: "File too large, max 5MB",
3647 }
3648 }
3649
3650 content, readErr := os.ReadFile(path)
3651 if readErr != nil {
3652 return util.InfoMsg{
3653 Type: util.InfoTypeError,
3654 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3655 }
3656 }
3657
3658 return message.Attachment{
3659 FilePath: path,
3660 FileName: filepath.Base(path),
3661 MimeType: mimeOf(content),
3662 Content: content,
3663 }
3664}
3665
3666var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3667
3668func (m *UI) pasteIdx() int {
3669 result := 0
3670 for _, at := range m.attachments.List() {
3671 found := pasteRE.FindStringSubmatch(at.FileName)
3672 if len(found) == 0 {
3673 continue
3674 }
3675 idx, err := strconv.Atoi(found[1])
3676 if err == nil {
3677 result = max(result, idx)
3678 }
3679 }
3680 return result + 1
3681}
3682
3683// drawSessionDetails draws the session details in compact mode.
3684func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3685 if m.session == nil {
3686 return
3687 }
3688
3689 s := m.com.Styles
3690
3691 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3692 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3693
3694 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3695 blocks := []string{
3696 title,
3697 "",
3698 m.modelInfo(width),
3699 "",
3700 }
3701
3702 detailsHeader := lipgloss.JoinVertical(
3703 lipgloss.Left,
3704 blocks...,
3705 )
3706
3707 version := s.CompactDetails.Version.Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3708
3709 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3710
3711 const maxSectionWidth = 50
3712 sectionWidth := max(1, min(maxSectionWidth, width/4-2)) // account for spacing between sections
3713 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3714
3715 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3716 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3717 skillsSection := m.skillsInfo(sectionWidth, maxItemsPerSection, false)
3718 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3719 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection, " ", skillsSection)
3720 uv.NewStyledString(
3721 s.CompactDetails.View.
3722 Width(area.Dx()).
3723 Render(
3724 lipgloss.JoinVertical(
3725 lipgloss.Left,
3726 detailsHeader,
3727 sections,
3728 version,
3729 ),
3730 ),
3731 ).Draw(scr, area)
3732}
3733
3734func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3735 load := func() tea.Msg {
3736 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3737 if err != nil {
3738 // TODO: make this better
3739 return util.ReportError(err)()
3740 }
3741
3742 if prompt == "" {
3743 return nil
3744 }
3745 return sendMessageMsg{
3746 Content: prompt,
3747 }
3748 }
3749
3750 var cmds []tea.Cmd
3751 if cmd := m.dialog.StartLoading(); cmd != nil {
3752 cmds = append(cmds, cmd)
3753 }
3754 cmds = append(cmds, load, func() tea.Msg {
3755 return closeDialogMsg{}
3756 })
3757
3758 return tea.Sequence(cmds...)
3759}
3760
3761func (m *UI) handleStateChanged() tea.Cmd {
3762 return func() tea.Msg {
3763 m.com.Workspace.UpdateAgentModel(context.Background())
3764 return mcpStateChangedMsg{
3765 states: m.com.Workspace.MCPGetStates(),
3766 }
3767 }
3768}
3769
3770func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3771 return func() tea.Msg {
3772 ws.MCPRefreshPrompts(context.Background(), name)
3773 return nil
3774 }
3775}
3776
3777func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3778 return func() tea.Msg {
3779 ws.RefreshMCPTools(context.Background(), name)
3780 return nil
3781 }
3782}
3783
3784func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3785 return func() tea.Msg {
3786 ws.MCPRefreshResources(context.Background(), name)
3787 return nil
3788 }
3789}
3790
3791func (m *UI) copyChatHighlight() tea.Cmd {
3792 text := m.chat.HighlightContent()
3793 return common.CopyToClipboardWithCallback(
3794 text,
3795 "Selected text copied to clipboard",
3796 func() tea.Msg {
3797 m.chat.ClearMouse()
3798 return nil
3799 },
3800 )
3801}
3802
3803func (m *UI) enableDockerMCP() tea.Msg {
3804 ctx := context.Background()
3805 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3806 return util.ReportError(err)()
3807 }
3808
3809 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3810}
3811
3812func (m *UI) disableDockerMCP() tea.Msg {
3813 if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3814 return util.ReportError(err)()
3815 }
3816
3817 return util.NewInfoMsg("Docker MCP disabled successfully")
3818}
3819
3820// renderLogo renders the Crush logo with the given styles and dimensions.
3821func renderLogo(t *styles.Styles, compact, hyper bool, width int) string {
3822 return logo.Render(t.Logo.GradCanvas, version.Version, compact, logo.Opts{
3823 FieldColor: t.Logo.FieldColor,
3824 TitleColorA: t.Logo.TitleColorA,
3825 TitleColorB: t.Logo.TitleColorB,
3826 CharmColor: t.Logo.CharmColor,
3827 VersionColor: t.Logo.VersionColor,
3828 Width: width,
3829 Hyper: hyper,
3830 })
3831}