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