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