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