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