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