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(binds,
2305 tab,
2306 commands,
2307 k.Models,
2308 )
2309
2310 switch m.focus {
2311 case uiFocusEditor:
2312 binds = append(binds,
2313 k.Editor.Newline,
2314 )
2315 case uiFocusMain:
2316 binds = append(binds,
2317 k.Chat.UpDown,
2318 k.Chat.UpDownOneItem,
2319 k.Chat.PageUp,
2320 k.Chat.PageDown,
2321 k.Chat.Copy,
2322 )
2323 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2324 binds = append(binds, k.Chat.PillLeft)
2325 }
2326 }
2327 default:
2328 // TODO: other states
2329 // if m.session == nil {
2330 // no session selected
2331 binds = append(binds,
2332 commands,
2333 k.Models,
2334 k.Editor.Newline,
2335 )
2336 }
2337
2338 binds = append(binds,
2339 k.Quit,
2340 k.Help,
2341 )
2342
2343 return binds
2344}
2345
2346// FullHelp implements [help.KeyMap].
2347func (m *UI) FullHelp() [][]key.Binding {
2348 var binds [][]key.Binding
2349 k := &m.keyMap
2350 help := k.Help
2351 help.SetHelp("ctrl+g", "less")
2352 hasAttachments := len(m.attachments.List()) > 0
2353 hasSession := m.hasSession()
2354 commands := k.Commands
2355 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2356 commands.SetHelp("/ or ctrl+p", "commands")
2357 }
2358
2359 switch m.state {
2360 case uiInitialize:
2361 binds = append(binds,
2362 []key.Binding{
2363 k.Quit,
2364 })
2365 case uiChat:
2366 // Show cancel binding if agent is busy.
2367 if m.isAgentBusy() {
2368 cancelBinding := k.Chat.Cancel
2369 if m.isCanceling {
2370 cancelBinding.SetHelp("esc", "press again to cancel")
2371 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2372 cancelBinding.SetHelp("esc", "clear queue")
2373 }
2374 binds = append(binds, []key.Binding{cancelBinding})
2375 }
2376
2377 mainBinds := []key.Binding{}
2378 tab := k.Tab
2379 if m.focus == uiFocusEditor {
2380 tab.SetHelp("tab", "focus chat")
2381 } else {
2382 tab.SetHelp("tab", "focus editor")
2383 }
2384
2385 mainBinds = append(mainBinds,
2386 tab,
2387 commands,
2388 k.Models,
2389 k.Sessions,
2390 )
2391 if hasSession {
2392 mainBinds = append(mainBinds, k.Chat.NewSession)
2393 }
2394
2395 binds = append(binds, mainBinds)
2396
2397 switch m.focus {
2398 case uiFocusEditor:
2399 editorBinds := []key.Binding{
2400 k.Editor.Newline,
2401 k.Editor.MentionFile,
2402 k.Editor.OpenEditor,
2403 }
2404 if m.currentModelSupportsImages() {
2405 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2406 }
2407 binds = append(binds, editorBinds)
2408 if hasAttachments {
2409 binds = append(binds,
2410 []key.Binding{
2411 k.Editor.AttachmentDeleteMode,
2412 k.Editor.DeleteAllAttachments,
2413 k.Editor.Escape,
2414 },
2415 )
2416 }
2417 case uiFocusMain:
2418 binds = append(binds,
2419 []key.Binding{
2420 k.Chat.UpDown,
2421 k.Chat.UpDownOneItem,
2422 k.Chat.PageUp,
2423 k.Chat.PageDown,
2424 },
2425 []key.Binding{
2426 k.Chat.HalfPageUp,
2427 k.Chat.HalfPageDown,
2428 k.Chat.Home,
2429 k.Chat.End,
2430 },
2431 []key.Binding{
2432 k.Chat.Copy,
2433 k.Chat.ClearHighlight,
2434 },
2435 )
2436 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2437 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2438 }
2439 }
2440 default:
2441 if m.session == nil {
2442 // no session selected
2443 binds = append(binds,
2444 []key.Binding{
2445 commands,
2446 k.Models,
2447 k.Sessions,
2448 },
2449 )
2450 editorBinds := []key.Binding{
2451 k.Editor.Newline,
2452 k.Editor.MentionFile,
2453 k.Editor.OpenEditor,
2454 }
2455 if m.currentModelSupportsImages() {
2456 editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
2457 }
2458 binds = append(binds, editorBinds)
2459 if hasAttachments {
2460 binds = append(binds,
2461 []key.Binding{
2462 k.Editor.AttachmentDeleteMode,
2463 k.Editor.DeleteAllAttachments,
2464 k.Editor.Escape,
2465 },
2466 )
2467 }
2468 }
2469 }
2470
2471 binds = append(binds,
2472 []key.Binding{
2473 help,
2474 k.Quit,
2475 },
2476 )
2477
2478 return binds
2479}
2480
2481func (m *UI) currentModelSupportsImages() bool {
2482 cfg := m.com.Config()
2483 if cfg == nil {
2484 return false
2485 }
2486 agentCfg, ok := cfg.Agents[config.AgentCoder]
2487 if !ok {
2488 return false
2489 }
2490 model := cfg.GetModelByType(agentCfg.Model)
2491 return model != nil && model.SupportsImages
2492}
2493
2494// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2495func (m *UI) toggleCompactMode() tea.Cmd {
2496 m.forceCompactMode = !m.forceCompactMode
2497
2498 err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2499 if err != nil {
2500 return util.ReportError(err)
2501 }
2502
2503 m.updateLayoutAndSize()
2504
2505 return nil
2506}
2507
2508// updateLayoutAndSize updates the layout and sizes of UI components.
2509func (m *UI) updateLayoutAndSize() {
2510 // Determine if we should be in compact mode
2511 if m.state == uiChat {
2512 if m.forceCompactMode {
2513 m.isCompact = true
2514 } else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2515 m.isCompact = true
2516 } else {
2517 m.isCompact = false
2518 }
2519 }
2520
2521 // First pass sizes components from the current textarea height.
2522 m.layout = m.generateLayout(m.width, m.height)
2523 prevHeight := m.textarea.Height()
2524 m.updateSize()
2525
2526 // SetWidth can change textarea height due to soft-wrap recalculation.
2527 // If that happens, run one reconciliation pass with the new height.
2528 if m.textarea.Height() != prevHeight {
2529 m.layout = m.generateLayout(m.width, m.height)
2530 m.updateSize()
2531 }
2532}
2533
2534// handleTextareaHeightChange checks whether the textarea height changed and,
2535// if so, recalculates the layout. When the chat is in follow mode it keeps
2536// the view scrolled to the bottom. The returned command, if non-nil, must be
2537// batched by the caller.
2538func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
2539 if m.textarea.Height() == prevHeight {
2540 return nil
2541 }
2542 m.updateLayoutAndSize()
2543 if m.state == uiChat && m.chat.Follow() {
2544 return m.chat.ScrollToBottomAndAnimate()
2545 }
2546 return nil
2547}
2548
2549// updateTextarea updates the textarea for msg and then reconciles layout if
2550// the textarea height changed as a result.
2551func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
2552 return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
2553}
2554
2555// updateTextareaWithPrevHeight is for cases when the height of the layout may
2556// have changed.
2557//
2558// Particularly, it's for cases where the textarea changes before
2559// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
2560// pass the height from before those changes took place so we can compare
2561// "before" vs "after" sizing and recalculate the layout if the textarea grew
2562// or shrank.
2563func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
2564 ta, cmd := m.textarea.Update(msg)
2565 m.textarea = ta
2566 return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
2567}
2568
2569// updateSize updates the sizes of UI components based on the current layout.
2570func (m *UI) updateSize() {
2571 // Set status width
2572 m.status.SetWidth(m.layout.status.Dx())
2573
2574 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2575 m.textarea.MaxHeight = TextareaMaxHeight
2576 m.textarea.SetWidth(m.layout.editor.Dx())
2577 m.renderPills()
2578
2579 // Handle different app states
2580 switch m.state {
2581 case uiChat:
2582 if !m.isCompact {
2583 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2584 }
2585 }
2586}
2587
2588// generateLayout calculates the layout rectangles for all UI components based
2589// on the current UI state and terminal dimensions.
2590func (m *UI) generateLayout(w, h int) uiLayout {
2591 // The screen area we're working with
2592 area := image.Rect(0, 0, w, h)
2593
2594 // The help height
2595 helpHeight := 1
2596 // The editor height: textarea height + margin for attachments and bottom spacing.
2597 editorHeight := m.textarea.Height() + editorHeightMargin
2598 // The sidebar width
2599 sidebarWidth := 30
2600 // The header height
2601 const landingHeaderHeight = 4
2602
2603 var helpKeyMap help.KeyMap = m
2604 if m.status != nil && m.status.ShowingAll() {
2605 for _, row := range helpKeyMap.FullHelp() {
2606 helpHeight = max(helpHeight, len(row))
2607 }
2608 }
2609
2610 // Add app margins
2611 var appRect, helpRect image.Rectangle
2612 layout.Vertical(
2613 layout.Len(area.Dy()-helpHeight),
2614 layout.Fill(1),
2615 ).Split(area).Assign(&appRect, &helpRect)
2616 appRect.Min.Y += 1
2617 appRect.Max.Y -= 1
2618 helpRect.Min.Y -= 1
2619 appRect.Min.X += 1
2620 appRect.Max.X -= 1
2621
2622 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2623 // extra padding on left and right for these states
2624 appRect.Min.X += 1
2625 appRect.Max.X -= 1
2626 }
2627
2628 uiLayout := uiLayout{
2629 area: area,
2630 status: helpRect,
2631 }
2632
2633 // Handle different app states
2634 switch m.state {
2635 case uiOnboarding, uiInitialize:
2636 // Layout
2637 //
2638 // header
2639 // ------
2640 // main
2641 // ------
2642 // help
2643
2644 var headerRect, mainRect image.Rectangle
2645 layout.Vertical(
2646 layout.Len(landingHeaderHeight),
2647 layout.Fill(1),
2648 ).Split(appRect).Assign(&headerRect, &mainRect)
2649 uiLayout.header = headerRect
2650 uiLayout.main = mainRect
2651
2652 case uiLanding:
2653 // Layout
2654 //
2655 // header
2656 // ------
2657 // main
2658 // ------
2659 // editor
2660 // ------
2661 // help
2662 var headerRect, mainRect image.Rectangle
2663 layout.Vertical(
2664 layout.Len(landingHeaderHeight),
2665 layout.Fill(1),
2666 ).Split(appRect).Assign(&headerRect, &mainRect)
2667 var editorRect image.Rectangle
2668 layout.Vertical(
2669 layout.Len(mainRect.Dy()-editorHeight),
2670 layout.Fill(1),
2671 ).Split(mainRect).Assign(&mainRect, &editorRect)
2672 // Remove extra padding from editor (but keep it for header and main)
2673 editorRect.Min.X -= 1
2674 editorRect.Max.X += 1
2675 uiLayout.header = headerRect
2676 uiLayout.main = mainRect
2677 uiLayout.editor = editorRect
2678
2679 case uiChat:
2680 if m.isCompact {
2681 // Layout
2682 //
2683 // compact-header
2684 // ------
2685 // main
2686 // ------
2687 // editor
2688 // ------
2689 // help
2690 const compactHeaderHeight = 1
2691 var headerRect, mainRect image.Rectangle
2692 layout.Vertical(
2693 layout.Len(compactHeaderHeight),
2694 layout.Fill(1),
2695 ).Split(appRect).Assign(&headerRect, &mainRect)
2696 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2697 var sessionDetailsArea image.Rectangle
2698 layout.Vertical(
2699 layout.Len(detailsHeight),
2700 layout.Fill(1),
2701 ).Split(appRect).Assign(&sessionDetailsArea, new(image.Rectangle))
2702 uiLayout.sessionDetails = sessionDetailsArea
2703 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2704 // Add one line gap between header and main content
2705 mainRect.Min.Y += 1
2706 var editorRect image.Rectangle
2707 layout.Vertical(
2708 layout.Len(mainRect.Dy()-editorHeight),
2709 layout.Fill(1),
2710 ).Split(mainRect).Assign(&mainRect, &editorRect)
2711 mainRect.Max.X -= 1 // Add padding right
2712 uiLayout.header = headerRect
2713 pillsHeight := m.pillsAreaHeight()
2714 if pillsHeight > 0 {
2715 pillsHeight = min(pillsHeight, mainRect.Dy())
2716 var chatRect, pillsRect image.Rectangle
2717 layout.Vertical(
2718 layout.Len(mainRect.Dy()-pillsHeight),
2719 layout.Fill(1),
2720 ).Split(mainRect).Assign(&chatRect, &pillsRect)
2721 uiLayout.main = chatRect
2722 uiLayout.pills = pillsRect
2723 } else {
2724 uiLayout.main = mainRect
2725 }
2726 // Add bottom margin to main
2727 uiLayout.main.Max.Y -= 1
2728 uiLayout.editor = editorRect
2729 } else {
2730 // Layout
2731 //
2732 // ------|---
2733 // main |
2734 // ------| side
2735 // editor|
2736 // ----------
2737 // help
2738
2739 var mainRect, sideRect image.Rectangle
2740 layout.Horizontal(
2741 layout.Len(appRect.Dx()-sidebarWidth),
2742 layout.Fill(1),
2743 ).Split(appRect).Assign(&mainRect, &sideRect)
2744 // Add padding left
2745 sideRect.Min.X += 1
2746 var editorRect image.Rectangle
2747 layout.Vertical(
2748 layout.Len(mainRect.Dy()-editorHeight),
2749 layout.Fill(1),
2750 ).Split(mainRect).Assign(&mainRect, &editorRect)
2751 mainRect.Max.X -= 1 // Add padding right
2752 uiLayout.sidebar = sideRect
2753 pillsHeight := m.pillsAreaHeight()
2754 if pillsHeight > 0 {
2755 pillsHeight = min(pillsHeight, mainRect.Dy())
2756 var chatRect, pillsRect image.Rectangle
2757 layout.Vertical(
2758 layout.Len(mainRect.Dy()-pillsHeight),
2759 layout.Fill(1),
2760 ).Split(mainRect).Assign(&chatRect, &pillsRect)
2761 uiLayout.main = chatRect
2762 uiLayout.pills = pillsRect
2763 } else {
2764 uiLayout.main = mainRect
2765 }
2766 // Add bottom margin to main
2767 uiLayout.main.Max.Y -= 1
2768 uiLayout.editor = editorRect
2769 }
2770 }
2771
2772 return uiLayout
2773}
2774
2775// uiLayout defines the positioning of UI elements.
2776type uiLayout struct {
2777 // area is the overall available area.
2778 area uv.Rectangle
2779
2780 // header is the header shown in special cases
2781 // e.x when the sidebar is collapsed
2782 // or when in the landing page
2783 // or in init/config
2784 header uv.Rectangle
2785
2786 // main is the area for the main pane. (e.x chat, configure, landing)
2787 main uv.Rectangle
2788
2789 // pills is the area for the pills panel.
2790 pills uv.Rectangle
2791
2792 // editor is the area for the editor pane.
2793 editor uv.Rectangle
2794
2795 // sidebar is the area for the sidebar.
2796 sidebar uv.Rectangle
2797
2798 // status is the area for the status view.
2799 status uv.Rectangle
2800
2801 // session details is the area for the session details overlay in compact mode.
2802 sessionDetails uv.Rectangle
2803}
2804
2805func (m *UI) openEditor(value string) tea.Cmd {
2806 tmpfile, err := os.CreateTemp("", "msg_*.md")
2807 if err != nil {
2808 return util.ReportError(err)
2809 }
2810 tmpPath := tmpfile.Name()
2811 defer tmpfile.Close() //nolint:errcheck
2812 if _, err := tmpfile.WriteString(value); err != nil {
2813 return util.ReportError(err)
2814 }
2815 cmd, err := editor.Command(
2816 "crush",
2817 tmpPath,
2818 editor.AtPosition(
2819 m.textarea.Line()+1,
2820 m.textarea.Column()+1,
2821 ),
2822 )
2823 if err != nil {
2824 return util.ReportError(err)
2825 }
2826 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2827 defer func() {
2828 _ = os.Remove(tmpPath)
2829 }()
2830
2831 if err != nil {
2832 return util.ReportError(err)
2833 }
2834 content, err := os.ReadFile(tmpPath)
2835 if err != nil {
2836 return util.ReportError(err)
2837 }
2838 if len(content) == 0 {
2839 return util.ReportWarn("Message is empty")
2840 }
2841 return openEditorMsg{
2842 Text: strings.TrimSpace(string(content)),
2843 }
2844 })
2845}
2846
2847// setEditorPrompt configures the textarea prompt function based on whether
2848// yolo mode is enabled.
2849func (m *UI) setEditorPrompt(yolo bool) {
2850 if yolo {
2851 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2852 return
2853 }
2854 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2855}
2856
2857// normalPromptFunc returns the normal editor prompt style (" > " on first
2858// line, "::: " on subsequent lines).
2859func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2860 t := m.com.Styles
2861 if info.LineNumber == 0 {
2862 if info.Focused {
2863 return " > "
2864 }
2865 return "::: "
2866 }
2867 if info.Focused {
2868 return t.Editor.PromptNormalFocused.Render()
2869 }
2870 return t.Editor.PromptNormalBlurred.Render()
2871}
2872
2873// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2874// and colored dots.
2875func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2876 t := m.com.Styles
2877 if info.LineNumber == 0 {
2878 if info.Focused {
2879 return t.Editor.PromptYoloIconFocused.Render()
2880 } else {
2881 return t.Editor.PromptYoloIconBlurred.Render()
2882 }
2883 }
2884 if info.Focused {
2885 return t.Editor.PromptYoloDotsFocused.Render()
2886 }
2887 return t.Editor.PromptYoloDotsBlurred.Render()
2888}
2889
2890// closeCompletions closes the completions popup and resets state.
2891func (m *UI) closeCompletions() {
2892 m.completionsOpen = false
2893 m.completionsQuery = ""
2894 m.completionsStartIndex = 0
2895 m.completions.Close()
2896}
2897
2898// insertCompletionText replaces the @query in the textarea with the given text.
2899// Returns false if the replacement cannot be performed.
2900func (m *UI) insertCompletionText(text string) bool {
2901 value := m.textarea.Value()
2902 if m.completionsStartIndex > len(value) {
2903 return false
2904 }
2905
2906 word := m.textareaWord()
2907 endIdx := min(m.completionsStartIndex+len(word), len(value))
2908 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2909 m.textarea.SetValue(newValue)
2910 m.textarea.MoveToEnd()
2911 m.textarea.InsertRune(' ')
2912 return true
2913}
2914
2915// insertFileCompletion inserts the selected file path into the textarea,
2916// replacing the @query, and adds the file as an attachment.
2917func (m *UI) insertFileCompletion(path string) tea.Cmd {
2918 prevHeight := m.textarea.Height()
2919 if !m.insertCompletionText(path) {
2920 return nil
2921 }
2922 heightCmd := m.handleTextareaHeightChange(prevHeight)
2923
2924 fileCmd := func() tea.Msg {
2925 absPath, _ := filepath.Abs(path)
2926
2927 if m.hasSession() {
2928 // Skip attachment if file was already read and hasn't been modified.
2929 lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2930 if !lastRead.IsZero() {
2931 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2932 return nil
2933 }
2934 }
2935 } else if slices.Contains(m.sessionFileReads, absPath) {
2936 return nil
2937 }
2938
2939 m.sessionFileReads = append(m.sessionFileReads, absPath)
2940
2941 // Add file as attachment.
2942 content, err := os.ReadFile(path)
2943 if err != nil {
2944 // If it fails, let the LLM handle it later.
2945 return nil
2946 }
2947
2948 return message.Attachment{
2949 FilePath: path,
2950 FileName: filepath.Base(path),
2951 MimeType: mimeOf(content),
2952 Content: content,
2953 }
2954 }
2955 return tea.Batch(heightCmd, fileCmd)
2956}
2957
2958// insertMCPResourceCompletion inserts the selected resource into the textarea,
2959// replacing the @query, and adds the resource as an attachment.
2960func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2961 displayText := cmp.Or(item.Title, item.URI)
2962
2963 prevHeight := m.textarea.Height()
2964 if !m.insertCompletionText(displayText) {
2965 return nil
2966 }
2967 heightCmd := m.handleTextareaHeightChange(prevHeight)
2968
2969 resourceCmd := func() tea.Msg {
2970 contents, err := m.com.Workspace.ReadMCPResource(
2971 context.Background(),
2972 item.MCPName,
2973 item.URI,
2974 )
2975 if err != nil {
2976 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2977 return nil
2978 }
2979 if len(contents) == 0 {
2980 return nil
2981 }
2982
2983 content := contents[0]
2984 var data []byte
2985 if content.Text != "" {
2986 data = []byte(content.Text)
2987 } else if len(content.Blob) > 0 {
2988 data = content.Blob
2989 }
2990 if len(data) == 0 {
2991 return nil
2992 }
2993
2994 mimeType := item.MIMEType
2995 if mimeType == "" && content.MIMEType != "" {
2996 mimeType = content.MIMEType
2997 }
2998 if mimeType == "" {
2999 mimeType = "text/plain"
3000 }
3001
3002 return message.Attachment{
3003 FilePath: item.URI,
3004 FileName: displayText,
3005 MimeType: mimeType,
3006 Content: data,
3007 }
3008 }
3009 return tea.Batch(heightCmd, resourceCmd)
3010}
3011
3012// completionsPosition returns the X and Y position for the completions popup.
3013func (m *UI) completionsPosition() image.Point {
3014 cur := m.textarea.Cursor()
3015 if cur == nil {
3016 return image.Point{
3017 X: m.layout.editor.Min.X,
3018 Y: m.layout.editor.Min.Y,
3019 }
3020 }
3021 return image.Point{
3022 X: cur.X + m.layout.editor.Min.X,
3023 Y: m.layout.editor.Min.Y + cur.Y,
3024 }
3025}
3026
3027// textareaWord returns the current word at the cursor position.
3028func (m *UI) textareaWord() string {
3029 return m.textarea.Word()
3030}
3031
3032// isWhitespace returns true if the byte is a whitespace character.
3033func isWhitespace(b byte) bool {
3034 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
3035}
3036
3037// isAgentBusy returns true if the agent coordinator exists and is currently
3038// busy processing a request.
3039func (m *UI) isAgentBusy() bool {
3040 return m.com.Workspace.AgentIsReady() &&
3041 m.com.Workspace.AgentIsBusy()
3042}
3043
3044// hasSession returns true if there is an active session with a valid ID.
3045func (m *UI) hasSession() bool {
3046 return m.session != nil && m.session.ID != ""
3047}
3048
3049// mimeOf detects the MIME type of the given content.
3050func mimeOf(content []byte) string {
3051 mimeBufferSize := min(512, len(content))
3052 return http.DetectContentType(content[:mimeBufferSize])
3053}
3054
3055var readyPlaceholders = [...]string{
3056 "Ready!",
3057 "Ready...",
3058 "Ready?",
3059 "Ready for instructions",
3060}
3061
3062var workingPlaceholders = [...]string{
3063 "Working!",
3064 "Working...",
3065 "Brrrrr...",
3066 "Prrrrrrrr...",
3067 "Processing...",
3068 "Thinking...",
3069}
3070
3071// randomizePlaceholders selects random placeholder text for the textarea's
3072// ready and working states.
3073func (m *UI) randomizePlaceholders() {
3074 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
3075 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
3076}
3077
3078// renderEditorView renders the editor view with attachments if any.
3079func (m *UI) renderEditorView(width int) string {
3080 var attachmentsView string
3081 if len(m.attachments.List()) > 0 {
3082 attachmentsView = m.attachments.Render(width)
3083 }
3084 return strings.Join([]string{
3085 attachmentsView,
3086 m.textarea.View(),
3087 "", // margin at bottom of editor
3088 }, "\n")
3089}
3090
3091// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
3092func (m *UI) cacheSidebarLogo(width int) {
3093 m.sidebarLogo = renderLogo(m.com.Styles, true, m.com.IsHyper(), width)
3094}
3095
3096// applyTheme replaces the active styles with the given theme, drops the
3097// shared markdown renderer cache, and refreshes every component that
3098// caches style data.
3099func (m *UI) applyTheme(s styles.Styles) {
3100 *m.com.Styles = s
3101 common.InvalidateMarkdownRendererCache()
3102 m.refreshStyles()
3103}
3104
3105// refreshStyles pushes the current *m.com.Styles into every subcomponent
3106// that copies or pre-renders style-dependent values at construction time.
3107func (m *UI) refreshStyles() {
3108 t := m.com.Styles
3109 m.header.refresh()
3110 if m.layout.sidebar.Dx() > 0 {
3111 m.cacheSidebarLogo(m.layout.sidebar.Dx())
3112 }
3113 m.textarea.SetStyles(t.Editor.Textarea)
3114 m.completions.SetStyles(t.Completions.Normal, t.Completions.Focused, t.Completions.Match)
3115 m.attachments.Renderer().SetStyles(
3116 t.Attachments.Normal,
3117 t.Attachments.Deleting,
3118 t.Attachments.Image,
3119 t.Attachments.Text,
3120 )
3121 m.todoSpinner.Style = t.Pills.TodoSpinner
3122 m.status.help.Styles = t.Help
3123 m.chat.InvalidateRenderCaches()
3124}
3125
3126// sendMessage sends a message with the given content and attachments.
3127func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
3128 if !m.com.Workspace.AgentIsReady() {
3129 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
3130 }
3131
3132 var cmds []tea.Cmd
3133 if !m.hasSession() {
3134 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3135 if err != nil {
3136 return util.ReportError(err)
3137 }
3138 if m.forceCompactMode {
3139 m.isCompact = true
3140 }
3141 if newSession.ID != "" {
3142 m.session = &newSession
3143 cmds = append(cmds, m.loadSession(newSession.ID))
3144 }
3145 m.setState(uiChat, m.focus)
3146 }
3147
3148 ctx := context.Background()
3149 cmds = append(cmds, func() tea.Msg {
3150 for _, path := range m.sessionFileReads {
3151 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
3152 m.com.Workspace.LSPStart(ctx, path)
3153 }
3154 return nil
3155 })
3156
3157 // Capture session ID to avoid race with main goroutine updating m.session.
3158 sessionID := m.session.ID
3159 cmds = append(cmds, func() tea.Msg {
3160 err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
3161 if err != nil {
3162 isCancelErr := errors.Is(err, context.Canceled)
3163 if isCancelErr {
3164 return nil
3165 }
3166 return util.InfoMsg{
3167 Type: util.InfoTypeError,
3168 Msg: fmt.Sprintf("%v", err),
3169 }
3170 }
3171 return nil
3172 })
3173 return tea.Batch(cmds...)
3174}
3175
3176const cancelTimerDuration = 2 * time.Second
3177
3178// cancelTimerCmd creates a command that expires the cancel timer.
3179func cancelTimerCmd() tea.Cmd {
3180 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
3181 return cancelTimerExpiredMsg{}
3182 })
3183}
3184
3185// cancelAgent handles the cancel key press. The first press sets isCanceling to true
3186// and starts a timer. The second press (before the timer expires) actually
3187// cancels the agent.
3188func (m *UI) cancelAgent() tea.Cmd {
3189 if !m.hasSession() {
3190 return nil
3191 }
3192
3193 if !m.com.Workspace.AgentIsReady() {
3194 return nil
3195 }
3196
3197 if m.isCanceling {
3198 // Second escape press - actually cancel the agent.
3199 m.isCanceling = false
3200 m.com.Workspace.AgentCancel(m.session.ID)
3201 // Stop the spinning todo indicator.
3202 m.todoIsSpinning = false
3203 m.renderPills()
3204 return nil
3205 }
3206
3207 // Check if there are queued prompts - if so, clear the queue.
3208 if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3209 m.com.Workspace.AgentClearQueue(m.session.ID)
3210 return nil
3211 }
3212
3213 // First escape press - set canceling state and start timer.
3214 m.isCanceling = true
3215 return cancelTimerCmd()
3216}
3217
3218// openDialog opens a dialog by its ID.
3219func (m *UI) openDialog(id string) tea.Cmd {
3220 var cmds []tea.Cmd
3221 switch id {
3222 case dialog.SessionsID:
3223 if cmd := m.openSessionsDialog(); cmd != nil {
3224 cmds = append(cmds, cmd)
3225 }
3226 case dialog.ModelsID:
3227 if cmd := m.openModelsDialog(); cmd != nil {
3228 cmds = append(cmds, cmd)
3229 }
3230 case dialog.CommandsID:
3231 if cmd := m.openCommandsDialog(); cmd != nil {
3232 cmds = append(cmds, cmd)
3233 }
3234 case dialog.ReasoningID:
3235 if cmd := m.openReasoningDialog(); cmd != nil {
3236 cmds = append(cmds, cmd)
3237 }
3238 case dialog.FilePickerID:
3239 if cmd := m.openFilesDialog(); cmd != nil {
3240 cmds = append(cmds, cmd)
3241 }
3242 case dialog.QuitID:
3243 if cmd := m.openQuitDialog(); cmd != nil {
3244 cmds = append(cmds, cmd)
3245 }
3246 default:
3247 // Unknown dialog
3248 break
3249 }
3250 return tea.Batch(cmds...)
3251}
3252
3253// openQuitDialog opens the quit confirmation dialog.
3254func (m *UI) openQuitDialog() tea.Cmd {
3255 if m.dialog.ContainsDialog(dialog.QuitID) {
3256 // Bring to front
3257 m.dialog.BringToFront(dialog.QuitID)
3258 return nil
3259 }
3260
3261 quitDialog := dialog.NewQuit(m.com)
3262 m.dialog.OpenDialog(quitDialog)
3263 return nil
3264}
3265
3266// openModelsDialog opens the models dialog.
3267func (m *UI) openModelsDialog() tea.Cmd {
3268 if m.dialog.ContainsDialog(dialog.ModelsID) {
3269 // Bring to front
3270 m.dialog.BringToFront(dialog.ModelsID)
3271 return nil
3272 }
3273
3274 isOnboarding := m.state == uiOnboarding
3275 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
3276 if err != nil {
3277 return util.ReportError(err)
3278 }
3279
3280 m.dialog.OpenDialog(modelsDialog)
3281
3282 return nil
3283}
3284
3285// openCommandsDialog opens the commands dialog.
3286func (m *UI) openCommandsDialog() tea.Cmd {
3287 if m.dialog.ContainsDialog(dialog.CommandsID) {
3288 // Bring to front
3289 m.dialog.BringToFront(dialog.CommandsID)
3290 return nil
3291 }
3292
3293 var sessionID string
3294 hasSession := m.session != nil
3295 if hasSession {
3296 sessionID = m.session.ID
3297 }
3298 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
3299 hasQueue := m.promptQueue > 0
3300
3301 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
3302 if err != nil {
3303 return util.ReportError(err)
3304 }
3305
3306 m.dialog.OpenDialog(commands)
3307
3308 return commands.InitialCmd()
3309}
3310
3311// openReasoningDialog opens the reasoning effort dialog.
3312func (m *UI) openReasoningDialog() tea.Cmd {
3313 if m.dialog.ContainsDialog(dialog.ReasoningID) {
3314 m.dialog.BringToFront(dialog.ReasoningID)
3315 return nil
3316 }
3317
3318 reasoningDialog, err := dialog.NewReasoning(m.com)
3319 if err != nil {
3320 return util.ReportError(err)
3321 }
3322
3323 m.dialog.OpenDialog(reasoningDialog)
3324 return nil
3325}
3326
3327// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3328// it brings it to the front. Otherwise, it will list all the sessions and open
3329// the dialog.
3330func (m *UI) openSessionsDialog() tea.Cmd {
3331 if m.dialog.ContainsDialog(dialog.SessionsID) {
3332 // Bring to front
3333 m.dialog.BringToFront(dialog.SessionsID)
3334 return nil
3335 }
3336
3337 selectedSessionID := ""
3338 if m.session != nil {
3339 selectedSessionID = m.session.ID
3340 }
3341
3342 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3343 if err != nil {
3344 return util.ReportError(err)
3345 }
3346
3347 m.dialog.OpenDialog(dialog)
3348 return nil
3349}
3350
3351// openFilesDialog opens the file picker dialog.
3352func (m *UI) openFilesDialog() tea.Cmd {
3353 if m.dialog.ContainsDialog(dialog.FilePickerID) {
3354 // Bring to front
3355 m.dialog.BringToFront(dialog.FilePickerID)
3356 return nil
3357 }
3358
3359 filePicker, cmd := dialog.NewFilePicker(m.com)
3360 filePicker.SetImageCapabilities(&m.caps)
3361 m.dialog.OpenDialog(filePicker)
3362
3363 return cmd
3364}
3365
3366// openPermissionsDialog opens the permissions dialog for a permission request.
3367func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3368 // Close any existing permissions dialog first.
3369 m.dialog.CloseDialog(dialog.PermissionsID)
3370
3371 // Get diff mode from config.
3372 var opts []dialog.PermissionsOption
3373 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3374 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3375 }
3376
3377 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3378 m.dialog.OpenDialog(permDialog)
3379 return nil
3380}
3381
3382// handlePermissionNotification updates tool items when permission state changes.
3383func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3384 toolItem := m.chat.MessageItem(notification.ToolCallID)
3385 if toolItem == nil {
3386 return
3387 }
3388
3389 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3390 if notification.Granted {
3391 permItem.SetStatus(chat.ToolStatusRunning)
3392 } else {
3393 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3394 }
3395 }
3396}
3397
3398// handleAgentNotification translates domain agent events into desktop
3399// notifications using the UI notification backend.
3400func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3401 switch n.Type {
3402 case notify.TypeAgentFinished:
3403 var cmds []tea.Cmd
3404 cmds = append(cmds, m.sendNotification(notification.Notification{
3405 Title: "Crush is waiting...",
3406 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3407 }))
3408 if m.com.IsHyper() {
3409 cmds = append(cmds, m.fetchHyperCredits())
3410 }
3411 return tea.Batch(cmds...)
3412 case notify.TypeReAuthenticate:
3413 return m.handleReAuthenticate(n.ProviderID)
3414 default:
3415 return nil
3416 }
3417}
3418
3419func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
3420 cfg := m.com.Config()
3421 if cfg == nil {
3422 return nil
3423 }
3424 providerCfg, ok := cfg.Providers.Get(providerID)
3425 if !ok {
3426 return nil
3427 }
3428 agentCfg, ok := cfg.Agents[config.AgentCoder]
3429 if !ok {
3430 return nil
3431 }
3432 return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
3433}
3434
3435// newSession clears the current session state and prepares for a new session.
3436// The actual session creation happens when the user sends their first message.
3437// Returns a command to reload prompt history.
3438func (m *UI) newSession() tea.Cmd {
3439 if !m.hasSession() {
3440 return nil
3441 }
3442
3443 m.session = nil
3444 m.sessionFiles = nil
3445 m.sessionFileReads = nil
3446 m.setState(uiLanding, uiFocusEditor)
3447 m.textarea.Focus()
3448 m.chat.Blur()
3449 m.chat.ClearMessages()
3450 m.pillsExpanded = false
3451 m.promptQueue = 0
3452 m.pillsView = ""
3453 m.historyReset()
3454 agenttools.ResetCache()
3455 return tea.Batch(
3456 func() tea.Msg {
3457 m.com.Workspace.LSPStopAll(context.Background())
3458 return nil
3459 },
3460 m.loadPromptHistory(),
3461 )
3462}
3463
3464// handlePasteMsg handles a paste message.
3465func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3466 // Normalize \r\n before the textarea sanitizer sees it.
3467 msg.Content = strings.ReplaceAll(msg.Content, "\r\n", "\n")
3468
3469 if m.dialog.HasDialogs() {
3470 return m.handleDialogMsg(msg)
3471 }
3472
3473 if m.focus != uiFocusEditor {
3474 return nil
3475 }
3476
3477 if hasPasteExceededThreshold(msg) {
3478 return func() tea.Msg {
3479 content := []byte(msg.Content)
3480 if int64(len(content)) > common.MaxAttachmentSize {
3481 return util.ReportWarn("Paste is too big (>5mb)")
3482 }
3483 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3484 mimeBufferSize := min(512, len(content))
3485 mimeType := http.DetectContentType(content[:mimeBufferSize])
3486 return message.Attachment{
3487 FileName: name,
3488 FilePath: name,
3489 MimeType: mimeType,
3490 Content: content,
3491 }
3492 }
3493 }
3494
3495 // Attempt to parse pasted content as file paths. If possible to parse,
3496 // all files exist and are valid, add as attachments.
3497 // Otherwise, paste as text.
3498 paths := fsext.ParsePastedFiles(msg.Content)
3499 allExistsAndValid := func() bool {
3500 if len(paths) == 0 {
3501 return false
3502 }
3503 for _, path := range paths {
3504 if _, err := os.Stat(path); os.IsNotExist(err) {
3505 return false
3506 }
3507
3508 lowerPath := strings.ToLower(path)
3509 isValid := false
3510 for _, ext := range common.AllowedImageTypes {
3511 if strings.HasSuffix(lowerPath, ext) {
3512 isValid = true
3513 break
3514 }
3515 }
3516 if !isValid {
3517 return false
3518 }
3519 }
3520 return true
3521 }
3522 if !allExistsAndValid() {
3523 prevHeight := m.textarea.Height()
3524 return m.updateTextareaWithPrevHeight(msg, prevHeight)
3525 }
3526
3527 var cmds []tea.Cmd
3528 for _, path := range paths {
3529 cmds = append(cmds, m.handleFilePathPaste(path))
3530 }
3531 return tea.Batch(cmds...)
3532}
3533
3534func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
3535 var (
3536 lineCount = 0
3537 colCount = 0
3538 )
3539 for line := range strings.SplitSeq(msg.Content, "\n") {
3540 lineCount++
3541 colCount = max(colCount, len(line))
3542
3543 if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
3544 return true
3545 }
3546 }
3547 return false
3548}
3549
3550// handleFilePathPaste handles a pasted file path.
3551func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3552 return func() tea.Msg {
3553 fileInfo, err := os.Stat(path)
3554 if err != nil {
3555 return util.ReportError(err)
3556 }
3557 if fileInfo.IsDir() {
3558 return util.ReportWarn("Cannot attach a directory")
3559 }
3560 if fileInfo.Size() > common.MaxAttachmentSize {
3561 return util.ReportWarn("File is too big (>5mb)")
3562 }
3563
3564 content, err := os.ReadFile(path)
3565 if err != nil {
3566 return util.ReportError(err)
3567 }
3568
3569 mimeBufferSize := min(512, len(content))
3570 mimeType := http.DetectContentType(content[:mimeBufferSize])
3571 fileName := filepath.Base(path)
3572 return message.Attachment{
3573 FilePath: path,
3574 FileName: fileName,
3575 MimeType: mimeType,
3576 Content: content,
3577 }
3578 }
3579}
3580
3581// pasteImageFromClipboard reads image data from the system clipboard and
3582// creates an attachment. If no image data is found, it falls back to
3583// interpreting clipboard text as a file path.
3584func (m *UI) pasteImageFromClipboard() tea.Msg {
3585 imageData, err := readClipboard(clipboardFormatImage)
3586 if int64(len(imageData)) > common.MaxAttachmentSize {
3587 return util.InfoMsg{
3588 Type: util.InfoTypeError,
3589 Msg: "File too large, max 5MB",
3590 }
3591 }
3592 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3593 if err == nil {
3594 return message.Attachment{
3595 FilePath: name,
3596 FileName: name,
3597 MimeType: mimeOf(imageData),
3598 Content: imageData,
3599 }
3600 }
3601
3602 textData, textErr := readClipboard(clipboardFormatText)
3603 if textErr != nil || len(textData) == 0 {
3604 return nil // Clipboard is empty or does not contain an image
3605 }
3606
3607 path := strings.TrimSpace(string(textData))
3608 path = strings.ReplaceAll(path, "\\ ", " ")
3609 if _, statErr := os.Stat(path); statErr != nil {
3610 return nil // Clipboard does not contain an image or valid file path
3611 }
3612
3613 lowerPath := strings.ToLower(path)
3614 isAllowed := false
3615 for _, ext := range common.AllowedImageTypes {
3616 if strings.HasSuffix(lowerPath, ext) {
3617 isAllowed = true
3618 break
3619 }
3620 }
3621 if !isAllowed {
3622 return util.NewInfoMsg("File type is not a supported image format")
3623 }
3624
3625 fileInfo, statErr := os.Stat(path)
3626 if statErr != nil {
3627 return util.InfoMsg{
3628 Type: util.InfoTypeError,
3629 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3630 }
3631 }
3632 if fileInfo.Size() > common.MaxAttachmentSize {
3633 return util.InfoMsg{
3634 Type: util.InfoTypeError,
3635 Msg: "File too large, max 5MB",
3636 }
3637 }
3638
3639 content, readErr := os.ReadFile(path)
3640 if readErr != nil {
3641 return util.InfoMsg{
3642 Type: util.InfoTypeError,
3643 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3644 }
3645 }
3646
3647 return message.Attachment{
3648 FilePath: path,
3649 FileName: filepath.Base(path),
3650 MimeType: mimeOf(content),
3651 Content: content,
3652 }
3653}
3654
3655var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3656
3657func (m *UI) pasteIdx() int {
3658 result := 0
3659 for _, at := range m.attachments.List() {
3660 found := pasteRE.FindStringSubmatch(at.FileName)
3661 if len(found) == 0 {
3662 continue
3663 }
3664 idx, err := strconv.Atoi(found[1])
3665 if err == nil {
3666 result = max(result, idx)
3667 }
3668 }
3669 return result + 1
3670}
3671
3672// drawSessionDetails draws the session details in compact mode.
3673func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3674 if m.session == nil {
3675 return
3676 }
3677
3678 s := m.com.Styles
3679
3680 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3681 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3682
3683 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3684 blocks := []string{
3685 title,
3686 "",
3687 m.modelInfo(width),
3688 "",
3689 }
3690
3691 detailsHeader := lipgloss.JoinVertical(
3692 lipgloss.Left,
3693 blocks...,
3694 )
3695
3696 version := s.CompactDetails.Version.Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3697
3698 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3699
3700 const maxSectionWidth = 50
3701 sectionWidth := max(1, min(maxSectionWidth, width/4-2)) // account for spacing between sections
3702 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3703
3704 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3705 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3706 skillsSection := m.skillsInfo(sectionWidth, maxItemsPerSection, false)
3707 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3708 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection, " ", skillsSection)
3709 uv.NewStyledString(
3710 s.CompactDetails.View.
3711 Width(area.Dx()).
3712 Render(
3713 lipgloss.JoinVertical(
3714 lipgloss.Left,
3715 detailsHeader,
3716 sections,
3717 version,
3718 ),
3719 ),
3720 ).Draw(scr, area)
3721}
3722
3723func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3724 load := func() tea.Msg {
3725 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3726 if err != nil {
3727 // TODO: make this better
3728 return util.ReportError(err)()
3729 }
3730
3731 if prompt == "" {
3732 return nil
3733 }
3734 return sendMessageMsg{
3735 Content: prompt,
3736 }
3737 }
3738
3739 var cmds []tea.Cmd
3740 if cmd := m.dialog.StartLoading(); cmd != nil {
3741 cmds = append(cmds, cmd)
3742 }
3743 cmds = append(cmds, load, func() tea.Msg {
3744 return closeDialogMsg{}
3745 })
3746
3747 return tea.Sequence(cmds...)
3748}
3749
3750func (m *UI) handleStateChanged() tea.Cmd {
3751 return func() tea.Msg {
3752 m.com.Workspace.UpdateAgentModel(context.Background())
3753 return mcpStateChangedMsg{
3754 states: m.com.Workspace.MCPGetStates(),
3755 }
3756 }
3757}
3758
3759func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3760 return func() tea.Msg {
3761 ws.MCPRefreshPrompts(context.Background(), name)
3762 return nil
3763 }
3764}
3765
3766func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3767 return func() tea.Msg {
3768 ws.RefreshMCPTools(context.Background(), name)
3769 return nil
3770 }
3771}
3772
3773func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3774 return func() tea.Msg {
3775 ws.MCPRefreshResources(context.Background(), name)
3776 return nil
3777 }
3778}
3779
3780func (m *UI) copyChatHighlight() tea.Cmd {
3781 text := m.chat.HighlightContent()
3782 return common.CopyToClipboardWithCallback(
3783 text,
3784 "Selected text copied to clipboard",
3785 func() tea.Msg {
3786 m.chat.ClearMouse()
3787 return nil
3788 },
3789 )
3790}
3791
3792func (m *UI) enableDockerMCP() tea.Msg {
3793 ctx := context.Background()
3794 if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3795 return util.ReportError(err)()
3796 }
3797
3798 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3799}
3800
3801func (m *UI) disableDockerMCP() tea.Msg {
3802 if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3803 return util.ReportError(err)()
3804 }
3805
3806 return util.NewInfoMsg("Docker MCP disabled successfully")
3807}
3808
3809// renderLogo renders the Crush logo with the given styles and dimensions.
3810func renderLogo(t *styles.Styles, compact, hyper bool, width int) string {
3811 return logo.Render(t.Logo.GradCanvas, version.Version, compact, logo.Opts{
3812 FieldColor: t.Logo.FieldColor,
3813 TitleColorA: t.Logo.TitleColorA,
3814 TitleColorB: t.Logo.TitleColorB,
3815 CharmColor: t.Logo.CharmColor,
3816 VersionColor: t.Logo.VersionColor,
3817 Width: width,
3818 Hyper: hyper,
3819 })
3820}