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