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