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