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