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