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 + 1 // Offset for attachments row
1933 return cur
1934 }
1935 }
1936 return nil
1937}
1938
1939// View renders the UI model's view.
1940func (m *UI) View() tea.View {
1941 var v tea.View
1942 v.AltScreen = true
1943 if !m.isTransparent {
1944 v.BackgroundColor = m.com.Styles.Background
1945 }
1946 v.MouseMode = tea.MouseModeCellMotion
1947 v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
1948
1949 canvas := uv.NewScreenBuffer(m.width, m.height)
1950 v.Cursor = m.Draw(canvas, canvas.Bounds())
1951
1952 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1953 contentLines := strings.Split(content, "\n")
1954 for i, line := range contentLines {
1955 // Trim trailing spaces for concise rendering
1956 contentLines[i] = strings.TrimRight(line, " ")
1957 }
1958
1959 content = strings.Join(contentLines, "\n")
1960
1961 v.Content = content
1962 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
1963 // HACK: use a random percentage to prevent ghostty from hiding it
1964 // after a timeout.
1965 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1966 }
1967
1968 return v
1969}
1970
1971// ShortHelp implements [help.KeyMap].
1972func (m *UI) ShortHelp() []key.Binding {
1973 var binds []key.Binding
1974 k := &m.keyMap
1975 tab := k.Tab
1976 commands := k.Commands
1977 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
1978 commands.SetHelp("/ or ctrl+p", "commands")
1979 }
1980
1981 switch m.state {
1982 case uiInitialize:
1983 binds = append(binds, k.Quit)
1984 case uiChat:
1985 // Show cancel binding if agent is busy.
1986 if m.isAgentBusy() {
1987 cancelBinding := k.Chat.Cancel
1988 if m.isCanceling {
1989 cancelBinding.SetHelp("esc", "press again to cancel")
1990 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1991 cancelBinding.SetHelp("esc", "clear queue")
1992 }
1993 binds = append(binds, cancelBinding)
1994 }
1995
1996 if m.focus == uiFocusEditor {
1997 tab.SetHelp("tab", "focus chat")
1998 } else {
1999 tab.SetHelp("tab", "focus editor")
2000 }
2001
2002 binds = append(binds,
2003 tab,
2004 commands,
2005 k.Models,
2006 )
2007
2008 switch m.focus {
2009 case uiFocusEditor:
2010 binds = append(binds,
2011 k.Editor.Newline,
2012 )
2013 case uiFocusMain:
2014 binds = append(binds,
2015 k.Chat.UpDown,
2016 k.Chat.UpDownOneItem,
2017 k.Chat.PageUp,
2018 k.Chat.PageDown,
2019 k.Chat.Copy,
2020 )
2021 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2022 binds = append(binds, k.Chat.PillLeft)
2023 }
2024 }
2025 default:
2026 // TODO: other states
2027 // if m.session == nil {
2028 // no session selected
2029 binds = append(binds,
2030 commands,
2031 k.Models,
2032 k.Editor.Newline,
2033 )
2034 }
2035
2036 binds = append(binds,
2037 k.Quit,
2038 k.Help,
2039 )
2040
2041 return binds
2042}
2043
2044// FullHelp implements [help.KeyMap].
2045func (m *UI) FullHelp() [][]key.Binding {
2046 var binds [][]key.Binding
2047 k := &m.keyMap
2048 help := k.Help
2049 help.SetHelp("ctrl+g", "less")
2050 hasAttachments := len(m.attachments.List()) > 0
2051 hasSession := m.hasSession()
2052 commands := k.Commands
2053 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2054 commands.SetHelp("/ or ctrl+p", "commands")
2055 }
2056
2057 switch m.state {
2058 case uiInitialize:
2059 binds = append(binds,
2060 []key.Binding{
2061 k.Quit,
2062 })
2063 case uiChat:
2064 // Show cancel binding if agent is busy.
2065 if m.isAgentBusy() {
2066 cancelBinding := k.Chat.Cancel
2067 if m.isCanceling {
2068 cancelBinding.SetHelp("esc", "press again to cancel")
2069 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2070 cancelBinding.SetHelp("esc", "clear queue")
2071 }
2072 binds = append(binds, []key.Binding{cancelBinding})
2073 }
2074
2075 mainBinds := []key.Binding{}
2076 tab := k.Tab
2077 if m.focus == uiFocusEditor {
2078 tab.SetHelp("tab", "focus chat")
2079 } else {
2080 tab.SetHelp("tab", "focus editor")
2081 }
2082
2083 mainBinds = append(mainBinds,
2084 tab,
2085 commands,
2086 k.Models,
2087 k.Sessions,
2088 )
2089 if hasSession {
2090 mainBinds = append(mainBinds, k.Chat.NewSession)
2091 }
2092
2093 binds = append(binds, mainBinds)
2094
2095 switch m.focus {
2096 case uiFocusEditor:
2097 binds = append(binds,
2098 []key.Binding{
2099 k.Editor.Newline,
2100 k.Editor.AddImage,
2101 k.Editor.PasteImage,
2102 k.Editor.MentionFile,
2103 k.Editor.OpenEditor,
2104 },
2105 )
2106 if hasAttachments {
2107 binds = append(binds,
2108 []key.Binding{
2109 k.Editor.AttachmentDeleteMode,
2110 k.Editor.DeleteAllAttachments,
2111 k.Editor.Escape,
2112 },
2113 )
2114 }
2115 case uiFocusMain:
2116 binds = append(binds,
2117 []key.Binding{
2118 k.Chat.UpDown,
2119 k.Chat.UpDownOneItem,
2120 k.Chat.PageUp,
2121 k.Chat.PageDown,
2122 },
2123 []key.Binding{
2124 k.Chat.HalfPageUp,
2125 k.Chat.HalfPageDown,
2126 k.Chat.Home,
2127 k.Chat.End,
2128 },
2129 []key.Binding{
2130 k.Chat.Copy,
2131 k.Chat.ClearHighlight,
2132 },
2133 )
2134 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2135 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2136 }
2137 }
2138 default:
2139 if m.session == nil {
2140 // no session selected
2141 binds = append(binds,
2142 []key.Binding{
2143 commands,
2144 k.Models,
2145 k.Sessions,
2146 },
2147 []key.Binding{
2148 k.Editor.Newline,
2149 k.Editor.AddImage,
2150 k.Editor.PasteImage,
2151 k.Editor.MentionFile,
2152 k.Editor.OpenEditor,
2153 },
2154 )
2155 if hasAttachments {
2156 binds = append(binds,
2157 []key.Binding{
2158 k.Editor.AttachmentDeleteMode,
2159 k.Editor.DeleteAllAttachments,
2160 k.Editor.Escape,
2161 },
2162 )
2163 }
2164 binds = append(binds,
2165 []key.Binding{
2166 help,
2167 },
2168 )
2169 }
2170 }
2171
2172 binds = append(binds,
2173 []key.Binding{
2174 help,
2175 k.Quit,
2176 },
2177 )
2178
2179 return binds
2180}
2181
2182// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2183func (m *UI) toggleCompactMode() tea.Cmd {
2184 m.forceCompactMode = !m.forceCompactMode
2185
2186 err := m.com.Config().SetCompactMode(m.forceCompactMode)
2187 if err != nil {
2188 return util.ReportError(err)
2189 }
2190
2191 m.updateLayoutAndSize()
2192
2193 return nil
2194}
2195
2196// updateLayoutAndSize updates the layout and sizes of UI components.
2197func (m *UI) updateLayoutAndSize() {
2198 // Determine if we should be in compact mode
2199 if m.state == uiChat {
2200 if m.forceCompactMode {
2201 m.isCompact = true
2202 return
2203 }
2204 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2205 m.isCompact = true
2206 } else {
2207 m.isCompact = false
2208 }
2209 }
2210
2211 m.layout = m.generateLayout(m.width, m.height)
2212 m.updateSize()
2213}
2214
2215// updateSize updates the sizes of UI components based on the current layout.
2216func (m *UI) updateSize() {
2217 // Set status width
2218 m.status.SetWidth(m.layout.status.Dx())
2219
2220 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2221 m.textarea.SetWidth(m.layout.editor.Dx())
2222 // TODO: Abstract the textarea and attachments into a single editor
2223 // component so we don't have to manually account for the attachments
2224 // height here.
2225 m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2226 m.renderPills()
2227
2228 // Handle different app states
2229 switch m.state {
2230 case uiChat:
2231 if !m.isCompact {
2232 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2233 }
2234 }
2235}
2236
2237// generateLayout calculates the layout rectangles for all UI components based
2238// on the current UI state and terminal dimensions.
2239func (m *UI) generateLayout(w, h int) uiLayout {
2240 // The screen area we're working with
2241 area := image.Rect(0, 0, w, h)
2242
2243 // The help height
2244 helpHeight := 1
2245 // The editor height
2246 editorHeight := 5
2247 // The sidebar width
2248 sidebarWidth := 30
2249 // The header height
2250 const landingHeaderHeight = 4
2251
2252 var helpKeyMap help.KeyMap = m
2253 if m.status != nil && m.status.ShowingAll() {
2254 for _, row := range helpKeyMap.FullHelp() {
2255 helpHeight = max(helpHeight, len(row))
2256 }
2257 }
2258
2259 // Add app margins
2260 appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2261 appRect.Min.Y += 1
2262 appRect.Max.Y -= 1
2263 helpRect.Min.Y -= 1
2264 appRect.Min.X += 1
2265 appRect.Max.X -= 1
2266
2267 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2268 // extra padding on left and right for these states
2269 appRect.Min.X += 1
2270 appRect.Max.X -= 1
2271 }
2272
2273 uiLayout := uiLayout{
2274 area: area,
2275 status: helpRect,
2276 }
2277
2278 // Handle different app states
2279 switch m.state {
2280 case uiOnboarding, uiInitialize:
2281 // Layout
2282 //
2283 // header
2284 // ------
2285 // main
2286 // ------
2287 // help
2288
2289 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2290 uiLayout.header = headerRect
2291 uiLayout.main = mainRect
2292
2293 case uiLanding:
2294 // Layout
2295 //
2296 // header
2297 // ------
2298 // main
2299 // ------
2300 // editor
2301 // ------
2302 // help
2303 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2304 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2305 // Remove extra padding from editor (but keep it for header and main)
2306 editorRect.Min.X -= 1
2307 editorRect.Max.X += 1
2308 uiLayout.header = headerRect
2309 uiLayout.main = mainRect
2310 uiLayout.editor = editorRect
2311
2312 case uiChat:
2313 if m.isCompact {
2314 // Layout
2315 //
2316 // compact-header
2317 // ------
2318 // main
2319 // ------
2320 // editor
2321 // ------
2322 // help
2323 const compactHeaderHeight = 1
2324 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2325 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2326 sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2327 uiLayout.sessionDetails = sessionDetailsArea
2328 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2329 // Add one line gap between header and main content
2330 mainRect.Min.Y += 1
2331 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2332 mainRect.Max.X -= 1 // Add padding right
2333 uiLayout.header = headerRect
2334 pillsHeight := m.pillsAreaHeight()
2335 if pillsHeight > 0 {
2336 pillsHeight = min(pillsHeight, mainRect.Dy())
2337 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2338 uiLayout.main = chatRect
2339 uiLayout.pills = pillsRect
2340 } else {
2341 uiLayout.main = mainRect
2342 }
2343 // Add bottom margin to main
2344 uiLayout.main.Max.Y -= 1
2345 uiLayout.editor = editorRect
2346 } else {
2347 // Layout
2348 //
2349 // ------|---
2350 // main |
2351 // ------| side
2352 // editor|
2353 // ----------
2354 // help
2355
2356 mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2357 // Add padding left
2358 sideRect.Min.X += 1
2359 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2360 mainRect.Max.X -= 1 // Add padding right
2361 uiLayout.sidebar = sideRect
2362 pillsHeight := m.pillsAreaHeight()
2363 if pillsHeight > 0 {
2364 pillsHeight = min(pillsHeight, mainRect.Dy())
2365 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2366 uiLayout.main = chatRect
2367 uiLayout.pills = pillsRect
2368 } else {
2369 uiLayout.main = mainRect
2370 }
2371 // Add bottom margin to main
2372 uiLayout.main.Max.Y -= 1
2373 uiLayout.editor = editorRect
2374 }
2375 }
2376
2377 return uiLayout
2378}
2379
2380// uiLayout defines the positioning of UI elements.
2381type uiLayout struct {
2382 // area is the overall available area.
2383 area uv.Rectangle
2384
2385 // header is the header shown in special cases
2386 // e.x when the sidebar is collapsed
2387 // or when in the landing page
2388 // or in init/config
2389 header uv.Rectangle
2390
2391 // main is the area for the main pane. (e.x chat, configure, landing)
2392 main uv.Rectangle
2393
2394 // pills is the area for the pills panel.
2395 pills uv.Rectangle
2396
2397 // editor is the area for the editor pane.
2398 editor uv.Rectangle
2399
2400 // sidebar is the area for the sidebar.
2401 sidebar uv.Rectangle
2402
2403 // status is the area for the status view.
2404 status uv.Rectangle
2405
2406 // session details is the area for the session details overlay in compact mode.
2407 sessionDetails uv.Rectangle
2408}
2409
2410func (m *UI) openEditor(value string) tea.Cmd {
2411 tmpfile, err := os.CreateTemp("", "msg_*.md")
2412 if err != nil {
2413 return util.ReportError(err)
2414 }
2415 defer tmpfile.Close() //nolint:errcheck
2416 if _, err := tmpfile.WriteString(value); err != nil {
2417 return util.ReportError(err)
2418 }
2419 cmd, err := editor.Command(
2420 "crush",
2421 tmpfile.Name(),
2422 editor.AtPosition(
2423 m.textarea.Line()+1,
2424 m.textarea.Column()+1,
2425 ),
2426 )
2427 if err != nil {
2428 return util.ReportError(err)
2429 }
2430 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2431 if err != nil {
2432 return util.ReportError(err)
2433 }
2434 content, err := os.ReadFile(tmpfile.Name())
2435 if err != nil {
2436 return util.ReportError(err)
2437 }
2438 if len(content) == 0 {
2439 return util.ReportWarn("Message is empty")
2440 }
2441 os.Remove(tmpfile.Name())
2442 return openEditorMsg{
2443 Text: strings.TrimSpace(string(content)),
2444 }
2445 })
2446}
2447
2448// setEditorPrompt configures the textarea prompt function based on whether
2449// yolo mode is enabled.
2450func (m *UI) setEditorPrompt(yolo bool) {
2451 if yolo {
2452 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2453 return
2454 }
2455 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2456}
2457
2458// normalPromptFunc returns the normal editor prompt style (" > " on first
2459// line, "::: " on subsequent lines).
2460func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2461 t := m.com.Styles
2462 if info.LineNumber == 0 {
2463 if info.Focused {
2464 return " > "
2465 }
2466 return "::: "
2467 }
2468 if info.Focused {
2469 return t.EditorPromptNormalFocused.Render()
2470 }
2471 return t.EditorPromptNormalBlurred.Render()
2472}
2473
2474// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2475// and colored dots.
2476func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2477 t := m.com.Styles
2478 if info.LineNumber == 0 {
2479 if info.Focused {
2480 return t.EditorPromptYoloIconFocused.Render()
2481 } else {
2482 return t.EditorPromptYoloIconBlurred.Render()
2483 }
2484 }
2485 if info.Focused {
2486 return t.EditorPromptYoloDotsFocused.Render()
2487 }
2488 return t.EditorPromptYoloDotsBlurred.Render()
2489}
2490
2491// closeCompletions closes the completions popup and resets state.
2492func (m *UI) closeCompletions() {
2493 m.completionsOpen = false
2494 m.completionsQuery = ""
2495 m.completionsStartIndex = 0
2496 m.completions.Close()
2497}
2498
2499// insertCompletionText replaces the @query in the textarea with the given text.
2500// Returns false if the replacement cannot be performed.
2501func (m *UI) insertCompletionText(text string) bool {
2502 value := m.textarea.Value()
2503 if m.completionsStartIndex > len(value) {
2504 return false
2505 }
2506
2507 word := m.textareaWord()
2508 endIdx := min(m.completionsStartIndex+len(word), len(value))
2509 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2510 m.textarea.SetValue(newValue)
2511 m.textarea.MoveToEnd()
2512 m.textarea.InsertRune(' ')
2513 return true
2514}
2515
2516// insertFileCompletion inserts the selected file path into the textarea,
2517// replacing the @query, and adds the file as an attachment.
2518func (m *UI) insertFileCompletion(path string) tea.Cmd {
2519 if !m.insertCompletionText(path) {
2520 return nil
2521 }
2522
2523 return func() tea.Msg {
2524 absPath, _ := filepath.Abs(path)
2525
2526 if m.hasSession() {
2527 // Skip attachment if file was already read and hasn't been modified.
2528 lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2529 if !lastRead.IsZero() {
2530 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2531 return nil
2532 }
2533 }
2534 } else if slices.Contains(m.sessionFileReads, absPath) {
2535 return nil
2536 }
2537
2538 m.sessionFileReads = append(m.sessionFileReads, absPath)
2539
2540 // Add file as attachment.
2541 content, err := os.ReadFile(path)
2542 if err != nil {
2543 // If it fails, let the LLM handle it later.
2544 return nil
2545 }
2546
2547 return message.Attachment{
2548 FilePath: path,
2549 FileName: filepath.Base(path),
2550 MimeType: mimeOf(content),
2551 Content: content,
2552 }
2553 }
2554}
2555
2556// insertMCPResourceCompletion inserts the selected resource into the textarea,
2557// replacing the @query, and adds the resource as an attachment.
2558func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2559 displayText := item.Title
2560 if displayText == "" {
2561 displayText = item.URI
2562 }
2563
2564 if !m.insertCompletionText(displayText) {
2565 return nil
2566 }
2567
2568 return func() tea.Msg {
2569 contents, err := mcp.ReadResource(
2570 context.Background(),
2571 m.com.Config(),
2572 item.MCPName,
2573 item.URI,
2574 )
2575 if err != nil {
2576 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2577 return nil
2578 }
2579 if len(contents) == 0 {
2580 return nil
2581 }
2582
2583 content := contents[0]
2584 var data []byte
2585 if content.Text != "" {
2586 data = []byte(content.Text)
2587 } else if len(content.Blob) > 0 {
2588 data = content.Blob
2589 }
2590 if len(data) == 0 {
2591 return nil
2592 }
2593
2594 mimeType := item.MIMEType
2595 if mimeType == "" && content.MIMEType != "" {
2596 mimeType = content.MIMEType
2597 }
2598 if mimeType == "" {
2599 mimeType = "text/plain"
2600 }
2601
2602 return message.Attachment{
2603 FilePath: item.URI,
2604 FileName: displayText,
2605 MimeType: mimeType,
2606 Content: data,
2607 }
2608 }
2609}
2610
2611// completionsPosition returns the X and Y position for the completions popup.
2612func (m *UI) completionsPosition() image.Point {
2613 cur := m.textarea.Cursor()
2614 if cur == nil {
2615 return image.Point{
2616 X: m.layout.editor.Min.X,
2617 Y: m.layout.editor.Min.Y,
2618 }
2619 }
2620 return image.Point{
2621 X: cur.X + m.layout.editor.Min.X,
2622 Y: m.layout.editor.Min.Y + cur.Y,
2623 }
2624}
2625
2626// textareaWord returns the current word at the cursor position.
2627func (m *UI) textareaWord() string {
2628 return m.textarea.Word()
2629}
2630
2631// isWhitespace returns true if the byte is a whitespace character.
2632func isWhitespace(b byte) bool {
2633 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2634}
2635
2636// isAgentBusy returns true if the agent coordinator exists and is currently
2637// busy processing a request.
2638func (m *UI) isAgentBusy() bool {
2639 return m.com.App != nil &&
2640 m.com.App.AgentCoordinator != nil &&
2641 m.com.App.AgentCoordinator.IsBusy()
2642}
2643
2644// hasSession returns true if there is an active session with a valid ID.
2645func (m *UI) hasSession() bool {
2646 return m.session != nil && m.session.ID != ""
2647}
2648
2649// mimeOf detects the MIME type of the given content.
2650func mimeOf(content []byte) string {
2651 mimeBufferSize := min(512, len(content))
2652 return http.DetectContentType(content[:mimeBufferSize])
2653}
2654
2655var readyPlaceholders = [...]string{
2656 "Ready!",
2657 "Ready...",
2658 "Ready?",
2659 "Ready for instructions",
2660}
2661
2662var workingPlaceholders = [...]string{
2663 "Working!",
2664 "Working...",
2665 "Brrrrr...",
2666 "Prrrrrrrr...",
2667 "Processing...",
2668 "Thinking...",
2669}
2670
2671// randomizePlaceholders selects random placeholder text for the textarea's
2672// ready and working states.
2673func (m *UI) randomizePlaceholders() {
2674 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2675 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2676}
2677
2678// renderEditorView renders the editor view with attachments if any.
2679func (m *UI) renderEditorView(width int) string {
2680 var attachmentsView string
2681 if len(m.attachments.List()) > 0 {
2682 attachmentsView = m.attachments.Render(width)
2683 }
2684 return strings.Join([]string{
2685 attachmentsView,
2686 m.textarea.View(),
2687 "", // margin at bottom of editor
2688 }, "\n")
2689}
2690
2691// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2692func (m *UI) cacheSidebarLogo(width int) {
2693 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2694}
2695
2696// sendMessage sends a message with the given content and attachments.
2697func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2698 if m.com.App.AgentCoordinator == nil {
2699 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2700 }
2701
2702 var cmds []tea.Cmd
2703 if !m.hasSession() {
2704 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2705 if err != nil {
2706 return util.ReportError(err)
2707 }
2708 if m.forceCompactMode {
2709 m.isCompact = true
2710 }
2711 if newSession.ID != "" {
2712 m.session = &newSession
2713 cmds = append(cmds, m.loadSession(newSession.ID))
2714 }
2715 m.setState(uiChat, m.focus)
2716 }
2717
2718 ctx := context.Background()
2719 cmds = append(cmds, func() tea.Msg {
2720 for _, path := range m.sessionFileReads {
2721 m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2722 m.com.App.LSPManager.Start(ctx, path)
2723 }
2724 return nil
2725 })
2726
2727 // Capture session ID to avoid race with main goroutine updating m.session.
2728 sessionID := m.session.ID
2729 cmds = append(cmds, func() tea.Msg {
2730 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2731 if err != nil {
2732 isCancelErr := errors.Is(err, context.Canceled)
2733 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2734 if isCancelErr || isPermissionErr {
2735 return nil
2736 }
2737 return util.InfoMsg{
2738 Type: util.InfoTypeError,
2739 Msg: err.Error(),
2740 }
2741 }
2742 return nil
2743 })
2744 return tea.Batch(cmds...)
2745}
2746
2747const cancelTimerDuration = 2 * time.Second
2748
2749// cancelTimerCmd creates a command that expires the cancel timer.
2750func cancelTimerCmd() tea.Cmd {
2751 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2752 return cancelTimerExpiredMsg{}
2753 })
2754}
2755
2756// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2757// and starts a timer. The second press (before the timer expires) actually
2758// cancels the agent.
2759func (m *UI) cancelAgent() tea.Cmd {
2760 if !m.hasSession() {
2761 return nil
2762 }
2763
2764 coordinator := m.com.App.AgentCoordinator
2765 if coordinator == nil {
2766 return nil
2767 }
2768
2769 if m.isCanceling {
2770 // Second escape press - actually cancel the agent.
2771 m.isCanceling = false
2772 coordinator.Cancel(m.session.ID)
2773 // Stop the spinning todo indicator.
2774 m.todoIsSpinning = false
2775 m.renderPills()
2776 return nil
2777 }
2778
2779 // Check if there are queued prompts - if so, clear the queue.
2780 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2781 coordinator.ClearQueue(m.session.ID)
2782 return nil
2783 }
2784
2785 // First escape press - set canceling state and start timer.
2786 m.isCanceling = true
2787 return cancelTimerCmd()
2788}
2789
2790// openDialog opens a dialog by its ID.
2791func (m *UI) openDialog(id string) tea.Cmd {
2792 var cmds []tea.Cmd
2793 switch id {
2794 case dialog.SessionsID:
2795 if cmd := m.openSessionsDialog(); cmd != nil {
2796 cmds = append(cmds, cmd)
2797 }
2798 case dialog.ModelsID:
2799 if cmd := m.openModelsDialog(); cmd != nil {
2800 cmds = append(cmds, cmd)
2801 }
2802 case dialog.CommandsID:
2803 if cmd := m.openCommandsDialog(); cmd != nil {
2804 cmds = append(cmds, cmd)
2805 }
2806 case dialog.ReasoningID:
2807 if cmd := m.openReasoningDialog(); cmd != nil {
2808 cmds = append(cmds, cmd)
2809 }
2810 case dialog.QuitID:
2811 if cmd := m.openQuitDialog(); cmd != nil {
2812 cmds = append(cmds, cmd)
2813 }
2814 default:
2815 // Unknown dialog
2816 break
2817 }
2818 return tea.Batch(cmds...)
2819}
2820
2821// openQuitDialog opens the quit confirmation dialog.
2822func (m *UI) openQuitDialog() tea.Cmd {
2823 if m.dialog.ContainsDialog(dialog.QuitID) {
2824 // Bring to front
2825 m.dialog.BringToFront(dialog.QuitID)
2826 return nil
2827 }
2828
2829 quitDialog := dialog.NewQuit(m.com)
2830 m.dialog.OpenDialog(quitDialog)
2831 return nil
2832}
2833
2834// openModelsDialog opens the models dialog.
2835func (m *UI) openModelsDialog() tea.Cmd {
2836 if m.dialog.ContainsDialog(dialog.ModelsID) {
2837 // Bring to front
2838 m.dialog.BringToFront(dialog.ModelsID)
2839 return nil
2840 }
2841
2842 isOnboarding := m.state == uiOnboarding
2843 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2844 if err != nil {
2845 return util.ReportError(err)
2846 }
2847
2848 m.dialog.OpenDialog(modelsDialog)
2849
2850 return nil
2851}
2852
2853// openCommandsDialog opens the commands dialog.
2854func (m *UI) openCommandsDialog() tea.Cmd {
2855 if m.dialog.ContainsDialog(dialog.CommandsID) {
2856 // Bring to front
2857 m.dialog.BringToFront(dialog.CommandsID)
2858 return nil
2859 }
2860
2861 sessionID := ""
2862 if m.session != nil {
2863 sessionID = m.session.ID
2864 }
2865
2866 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2867 if err != nil {
2868 return util.ReportError(err)
2869 }
2870
2871 m.dialog.OpenDialog(commands)
2872
2873 return nil
2874}
2875
2876// openReasoningDialog opens the reasoning effort dialog.
2877func (m *UI) openReasoningDialog() tea.Cmd {
2878 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2879 m.dialog.BringToFront(dialog.ReasoningID)
2880 return nil
2881 }
2882
2883 reasoningDialog, err := dialog.NewReasoning(m.com)
2884 if err != nil {
2885 return util.ReportError(err)
2886 }
2887
2888 m.dialog.OpenDialog(reasoningDialog)
2889 return nil
2890}
2891
2892// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2893// it brings it to the front. Otherwise, it will list all the sessions and open
2894// the dialog.
2895func (m *UI) openSessionsDialog() tea.Cmd {
2896 if m.dialog.ContainsDialog(dialog.SessionsID) {
2897 // Bring to front
2898 m.dialog.BringToFront(dialog.SessionsID)
2899 return nil
2900 }
2901
2902 selectedSessionID := ""
2903 if m.session != nil {
2904 selectedSessionID = m.session.ID
2905 }
2906
2907 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2908 if err != nil {
2909 return util.ReportError(err)
2910 }
2911
2912 m.dialog.OpenDialog(dialog)
2913 return nil
2914}
2915
2916// openFilesDialog opens the file picker dialog.
2917func (m *UI) openFilesDialog() tea.Cmd {
2918 if m.dialog.ContainsDialog(dialog.FilePickerID) {
2919 // Bring to front
2920 m.dialog.BringToFront(dialog.FilePickerID)
2921 return nil
2922 }
2923
2924 filePicker, cmd := dialog.NewFilePicker(m.com)
2925 filePicker.SetImageCapabilities(&m.caps)
2926 m.dialog.OpenDialog(filePicker)
2927
2928 return cmd
2929}
2930
2931// openPermissionsDialog opens the permissions dialog for a permission request.
2932func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2933 // Close any existing permissions dialog first.
2934 m.dialog.CloseDialog(dialog.PermissionsID)
2935
2936 // Get diff mode from config.
2937 var opts []dialog.PermissionsOption
2938 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2939 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2940 }
2941
2942 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2943 m.dialog.OpenDialog(permDialog)
2944 return nil
2945}
2946
2947// handlePermissionNotification updates tool items when permission state changes.
2948func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2949 toolItem := m.chat.MessageItem(notification.ToolCallID)
2950 if toolItem == nil {
2951 return
2952 }
2953
2954 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2955 if notification.Granted {
2956 permItem.SetStatus(chat.ToolStatusRunning)
2957 } else {
2958 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2959 }
2960 }
2961}
2962
2963// newSession clears the current session state and prepares for a new session.
2964// The actual session creation happens when the user sends their first message.
2965// Returns a command to reload prompt history.
2966func (m *UI) newSession() tea.Cmd {
2967 if !m.hasSession() {
2968 return nil
2969 }
2970
2971 m.session = nil
2972 m.sessionFiles = nil
2973 m.sessionFileReads = nil
2974 m.setState(uiLanding, uiFocusEditor)
2975 m.textarea.Focus()
2976 m.chat.Blur()
2977 m.chat.ClearMessages()
2978 m.pillsExpanded = false
2979 m.promptQueue = 0
2980 m.pillsView = ""
2981 m.historyReset()
2982 agenttools.ResetCache()
2983 return tea.Batch(
2984 func() tea.Msg {
2985 m.com.App.LSPManager.StopAll(context.Background())
2986 return nil
2987 },
2988 m.loadPromptHistory(),
2989 )
2990}
2991
2992// handlePasteMsg handles a paste message.
2993func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2994 if m.dialog.HasDialogs() {
2995 return m.handleDialogMsg(msg)
2996 }
2997
2998 if m.focus != uiFocusEditor {
2999 return nil
3000 }
3001
3002 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3003 return func() tea.Msg {
3004 content := []byte(msg.Content)
3005 if int64(len(content)) > common.MaxAttachmentSize {
3006 return util.ReportWarn("Paste is too big (>5mb)")
3007 }
3008 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3009 mimeBufferSize := min(512, len(content))
3010 mimeType := http.DetectContentType(content[:mimeBufferSize])
3011 return message.Attachment{
3012 FileName: name,
3013 FilePath: name,
3014 MimeType: mimeType,
3015 Content: content,
3016 }
3017 }
3018 }
3019
3020 // Attempt to parse pasted content as file paths. If possible to parse,
3021 // all files exist and are valid, add as attachments.
3022 // Otherwise, paste as text.
3023 paths := fsext.ParsePastedFiles(msg.Content)
3024 allExistsAndValid := func() bool {
3025 if len(paths) == 0 {
3026 return false
3027 }
3028 for _, path := range paths {
3029 if _, err := os.Stat(path); os.IsNotExist(err) {
3030 return false
3031 }
3032
3033 lowerPath := strings.ToLower(path)
3034 isValid := false
3035 for _, ext := range common.AllowedImageTypes {
3036 if strings.HasSuffix(lowerPath, ext) {
3037 isValid = true
3038 break
3039 }
3040 }
3041 if !isValid {
3042 return false
3043 }
3044 }
3045 return true
3046 }
3047 if !allExistsAndValid() {
3048 var cmd tea.Cmd
3049 m.textarea, cmd = m.textarea.Update(msg)
3050 return cmd
3051 }
3052
3053 var cmds []tea.Cmd
3054 for _, path := range paths {
3055 cmds = append(cmds, m.handleFilePathPaste(path))
3056 }
3057 return tea.Batch(cmds...)
3058}
3059
3060// handleFilePathPaste handles a pasted file path.
3061func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3062 return func() tea.Msg {
3063 fileInfo, err := os.Stat(path)
3064 if err != nil {
3065 return util.ReportError(err)
3066 }
3067 if fileInfo.IsDir() {
3068 return util.ReportWarn("Cannot attach a directory")
3069 }
3070 if fileInfo.Size() > common.MaxAttachmentSize {
3071 return util.ReportWarn("File is too big (>5mb)")
3072 }
3073
3074 content, err := os.ReadFile(path)
3075 if err != nil {
3076 return util.ReportError(err)
3077 }
3078
3079 mimeBufferSize := min(512, len(content))
3080 mimeType := http.DetectContentType(content[:mimeBufferSize])
3081 fileName := filepath.Base(path)
3082 return message.Attachment{
3083 FilePath: path,
3084 FileName: fileName,
3085 MimeType: mimeType,
3086 Content: content,
3087 }
3088 }
3089}
3090
3091// pasteImageFromClipboard reads image data from the system clipboard and
3092// creates an attachment. If no image data is found, it falls back to
3093// interpreting clipboard text as a file path.
3094func (m *UI) pasteImageFromClipboard() tea.Msg {
3095 imageData, err := readClipboard(clipboardFormatImage)
3096 if int64(len(imageData)) > common.MaxAttachmentSize {
3097 return util.InfoMsg{
3098 Type: util.InfoTypeError,
3099 Msg: "File too large, max 5MB",
3100 }
3101 }
3102 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3103 if err == nil {
3104 return message.Attachment{
3105 FilePath: name,
3106 FileName: name,
3107 MimeType: mimeOf(imageData),
3108 Content: imageData,
3109 }
3110 }
3111
3112 textData, textErr := readClipboard(clipboardFormatText)
3113 if textErr != nil || len(textData) == 0 {
3114 return util.NewInfoMsg("Clipboard is empty or does not contain an image")
3115 }
3116
3117 path := strings.TrimSpace(string(textData))
3118 path = strings.ReplaceAll(path, "\\ ", " ")
3119 if _, statErr := os.Stat(path); statErr != nil {
3120 return util.NewInfoMsg("Clipboard does not contain an image or valid file path")
3121 }
3122
3123 lowerPath := strings.ToLower(path)
3124 isAllowed := false
3125 for _, ext := range common.AllowedImageTypes {
3126 if strings.HasSuffix(lowerPath, ext) {
3127 isAllowed = true
3128 break
3129 }
3130 }
3131 if !isAllowed {
3132 return util.NewInfoMsg("File type is not a supported image format")
3133 }
3134
3135 fileInfo, statErr := os.Stat(path)
3136 if statErr != nil {
3137 return util.InfoMsg{
3138 Type: util.InfoTypeError,
3139 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3140 }
3141 }
3142 if fileInfo.Size() > common.MaxAttachmentSize {
3143 return util.InfoMsg{
3144 Type: util.InfoTypeError,
3145 Msg: "File too large, max 5MB",
3146 }
3147 }
3148
3149 content, readErr := os.ReadFile(path)
3150 if readErr != nil {
3151 return util.InfoMsg{
3152 Type: util.InfoTypeError,
3153 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3154 }
3155 }
3156
3157 return message.Attachment{
3158 FilePath: path,
3159 FileName: filepath.Base(path),
3160 MimeType: mimeOf(content),
3161 Content: content,
3162 }
3163}
3164
3165var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3166
3167func (m *UI) pasteIdx() int {
3168 result := 0
3169 for _, at := range m.attachments.List() {
3170 found := pasteRE.FindStringSubmatch(at.FileName)
3171 if len(found) == 0 {
3172 continue
3173 }
3174 idx, err := strconv.Atoi(found[1])
3175 if err == nil {
3176 result = max(result, idx)
3177 }
3178 }
3179 return result + 1
3180}
3181
3182// drawSessionDetails draws the session details in compact mode.
3183func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3184 if m.session == nil {
3185 return
3186 }
3187
3188 s := m.com.Styles
3189
3190 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3191 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3192
3193 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3194 blocks := []string{
3195 title,
3196 "",
3197 m.modelInfo(width),
3198 "",
3199 }
3200
3201 detailsHeader := lipgloss.JoinVertical(
3202 lipgloss.Left,
3203 blocks...,
3204 )
3205
3206 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3207
3208 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3209
3210 const maxSectionWidth = 50
3211 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3212 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3213
3214 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3215 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3216 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3217 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3218 uv.NewStyledString(
3219 s.CompactDetails.View.
3220 Width(area.Dx()).
3221 Render(
3222 lipgloss.JoinVertical(
3223 lipgloss.Left,
3224 detailsHeader,
3225 sections,
3226 version,
3227 ),
3228 ),
3229 ).Draw(scr, area)
3230}
3231
3232func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3233 load := func() tea.Msg {
3234 prompt, err := commands.GetMCPPrompt(m.com.Config(), clientID, promptID, arguments)
3235 if err != nil {
3236 // TODO: make this better
3237 return util.ReportError(err)()
3238 }
3239
3240 if prompt == "" {
3241 return nil
3242 }
3243 return sendMessageMsg{
3244 Content: prompt,
3245 }
3246 }
3247
3248 var cmds []tea.Cmd
3249 if cmd := m.dialog.StartLoading(); cmd != nil {
3250 cmds = append(cmds, cmd)
3251 }
3252 cmds = append(cmds, load, func() tea.Msg {
3253 return closeDialogMsg{}
3254 })
3255
3256 return tea.Sequence(cmds...)
3257}
3258
3259func (m *UI) handleStateChanged() tea.Cmd {
3260 return func() tea.Msg {
3261 m.com.App.UpdateAgentModel(context.Background())
3262 return mcpStateChangedMsg{
3263 states: mcp.GetStates(),
3264 }
3265 }
3266}
3267
3268func handleMCPPromptsEvent(name string) tea.Cmd {
3269 return func() tea.Msg {
3270 mcp.RefreshPrompts(context.Background(), name)
3271 return nil
3272 }
3273}
3274
3275func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3276 return func() tea.Msg {
3277 mcp.RefreshTools(
3278 context.Background(),
3279 cfg,
3280 name,
3281 )
3282 return nil
3283 }
3284}
3285
3286func handleMCPResourcesEvent(name string) tea.Cmd {
3287 return func() tea.Msg {
3288 mcp.RefreshResources(context.Background(), name)
3289 return nil
3290 }
3291}
3292
3293func (m *UI) copyChatHighlight() tea.Cmd {
3294 text := m.chat.HighlightContent()
3295 return common.CopyToClipboardWithCallback(
3296 text,
3297 "Selected text copied to clipboard",
3298 func() tea.Msg {
3299 m.chat.ClearMouse()
3300 return nil
3301 },
3302 )
3303}
3304
3305// renderLogo renders the Crush logo with the given styles and dimensions.
3306func renderLogo(t *styles.Styles, compact bool, width int) string {
3307 return logo.Render(t, version.Version, compact, logo.Opts{
3308 FieldColor: t.LogoFieldColor,
3309 TitleColorA: t.LogoTitleColorA,
3310 TitleColorB: t.LogoTitleColorB,
3311 CharmColor: t.LogoCharmColor,
3312 VersionColor: t.LogoVersionColor,
3313 Width: width,
3314 })
3315}