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 if !m.insertCompletionText(item.URI) {
2560 return nil
2561 }
2562
2563 return func() tea.Msg {
2564 contents, err := mcp.ReadResource(
2565 context.Background(),
2566 m.com.Config(),
2567 item.MCPName,
2568 item.URI,
2569 )
2570 if err != nil {
2571 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2572 return nil
2573 }
2574 if len(contents) == 0 {
2575 return nil
2576 }
2577
2578 content := contents[0]
2579 var data []byte
2580 if content.Text != "" {
2581 data = []byte(content.Text)
2582 } else if len(content.Blob) > 0 {
2583 data = content.Blob
2584 }
2585 if len(data) == 0 {
2586 return nil
2587 }
2588
2589 mimeType := item.MIMEType
2590 if mimeType == "" && content.MIMEType != "" {
2591 mimeType = content.MIMEType
2592 }
2593 if mimeType == "" {
2594 mimeType = "text/plain"
2595 }
2596
2597 return message.Attachment{
2598 FilePath: item.URI,
2599 FileName: item.URI,
2600 MimeType: mimeType,
2601 Content: data,
2602 }
2603 }
2604}
2605
2606// completionsPosition returns the X and Y position for the completions popup.
2607func (m *UI) completionsPosition() image.Point {
2608 cur := m.textarea.Cursor()
2609 if cur == nil {
2610 return image.Point{
2611 X: m.layout.editor.Min.X,
2612 Y: m.layout.editor.Min.Y,
2613 }
2614 }
2615 return image.Point{
2616 X: cur.X + m.layout.editor.Min.X,
2617 Y: m.layout.editor.Min.Y + cur.Y,
2618 }
2619}
2620
2621// textareaWord returns the current word at the cursor position.
2622func (m *UI) textareaWord() string {
2623 return m.textarea.Word()
2624}
2625
2626// isWhitespace returns true if the byte is a whitespace character.
2627func isWhitespace(b byte) bool {
2628 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2629}
2630
2631// isAgentBusy returns true if the agent coordinator exists and is currently
2632// busy processing a request.
2633func (m *UI) isAgentBusy() bool {
2634 return m.com.App != nil &&
2635 m.com.App.AgentCoordinator != nil &&
2636 m.com.App.AgentCoordinator.IsBusy()
2637}
2638
2639// hasSession returns true if there is an active session with a valid ID.
2640func (m *UI) hasSession() bool {
2641 return m.session != nil && m.session.ID != ""
2642}
2643
2644// mimeOf detects the MIME type of the given content.
2645func mimeOf(content []byte) string {
2646 mimeBufferSize := min(512, len(content))
2647 return http.DetectContentType(content[:mimeBufferSize])
2648}
2649
2650var readyPlaceholders = [...]string{
2651 "Ready!",
2652 "Ready...",
2653 "Ready?",
2654 "Ready for instructions",
2655}
2656
2657var workingPlaceholders = [...]string{
2658 "Working!",
2659 "Working...",
2660 "Brrrrr...",
2661 "Prrrrrrrr...",
2662 "Processing...",
2663 "Thinking...",
2664}
2665
2666// randomizePlaceholders selects random placeholder text for the textarea's
2667// ready and working states.
2668func (m *UI) randomizePlaceholders() {
2669 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2670 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2671}
2672
2673// renderEditorView renders the editor view with attachments if any.
2674func (m *UI) renderEditorView(width int) string {
2675 var attachmentsView string
2676 if len(m.attachments.List()) > 0 {
2677 attachmentsView = m.attachments.Render(width)
2678 }
2679 return strings.Join([]string{
2680 attachmentsView,
2681 m.textarea.View(),
2682 "", // margin at bottom of editor
2683 }, "\n")
2684}
2685
2686// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2687func (m *UI) cacheSidebarLogo(width int) {
2688 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2689}
2690
2691// sendMessage sends a message with the given content and attachments.
2692func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2693 if m.com.App.AgentCoordinator == nil {
2694 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2695 }
2696
2697 var cmds []tea.Cmd
2698 if !m.hasSession() {
2699 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2700 if err != nil {
2701 return util.ReportError(err)
2702 }
2703 if m.forceCompactMode {
2704 m.isCompact = true
2705 }
2706 if newSession.ID != "" {
2707 m.session = &newSession
2708 cmds = append(cmds, m.loadSession(newSession.ID))
2709 }
2710 m.setState(uiChat, m.focus)
2711 }
2712
2713 ctx := context.Background()
2714 cmds = append(cmds, func() tea.Msg {
2715 for _, path := range m.sessionFileReads {
2716 m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2717 m.com.App.LSPManager.Start(ctx, path)
2718 }
2719 return nil
2720 })
2721
2722 // Capture session ID to avoid race with main goroutine updating m.session.
2723 sessionID := m.session.ID
2724 cmds = append(cmds, func() tea.Msg {
2725 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2726 if err != nil {
2727 isCancelErr := errors.Is(err, context.Canceled)
2728 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2729 if isCancelErr || isPermissionErr {
2730 return nil
2731 }
2732 return util.InfoMsg{
2733 Type: util.InfoTypeError,
2734 Msg: err.Error(),
2735 }
2736 }
2737 return nil
2738 })
2739 return tea.Batch(cmds...)
2740}
2741
2742const cancelTimerDuration = 2 * time.Second
2743
2744// cancelTimerCmd creates a command that expires the cancel timer.
2745func cancelTimerCmd() tea.Cmd {
2746 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2747 return cancelTimerExpiredMsg{}
2748 })
2749}
2750
2751// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2752// and starts a timer. The second press (before the timer expires) actually
2753// cancels the agent.
2754func (m *UI) cancelAgent() tea.Cmd {
2755 if !m.hasSession() {
2756 return nil
2757 }
2758
2759 coordinator := m.com.App.AgentCoordinator
2760 if coordinator == nil {
2761 return nil
2762 }
2763
2764 if m.isCanceling {
2765 // Second escape press - actually cancel the agent.
2766 m.isCanceling = false
2767 coordinator.Cancel(m.session.ID)
2768 // Stop the spinning todo indicator.
2769 m.todoIsSpinning = false
2770 m.renderPills()
2771 return nil
2772 }
2773
2774 // Check if there are queued prompts - if so, clear the queue.
2775 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2776 coordinator.ClearQueue(m.session.ID)
2777 return nil
2778 }
2779
2780 // First escape press - set canceling state and start timer.
2781 m.isCanceling = true
2782 return cancelTimerCmd()
2783}
2784
2785// openDialog opens a dialog by its ID.
2786func (m *UI) openDialog(id string) tea.Cmd {
2787 var cmds []tea.Cmd
2788 switch id {
2789 case dialog.SessionsID:
2790 if cmd := m.openSessionsDialog(); cmd != nil {
2791 cmds = append(cmds, cmd)
2792 }
2793 case dialog.ModelsID:
2794 if cmd := m.openModelsDialog(); cmd != nil {
2795 cmds = append(cmds, cmd)
2796 }
2797 case dialog.CommandsID:
2798 if cmd := m.openCommandsDialog(); cmd != nil {
2799 cmds = append(cmds, cmd)
2800 }
2801 case dialog.ReasoningID:
2802 if cmd := m.openReasoningDialog(); cmd != nil {
2803 cmds = append(cmds, cmd)
2804 }
2805 case dialog.QuitID:
2806 if cmd := m.openQuitDialog(); cmd != nil {
2807 cmds = append(cmds, cmd)
2808 }
2809 default:
2810 // Unknown dialog
2811 break
2812 }
2813 return tea.Batch(cmds...)
2814}
2815
2816// openQuitDialog opens the quit confirmation dialog.
2817func (m *UI) openQuitDialog() tea.Cmd {
2818 if m.dialog.ContainsDialog(dialog.QuitID) {
2819 // Bring to front
2820 m.dialog.BringToFront(dialog.QuitID)
2821 return nil
2822 }
2823
2824 quitDialog := dialog.NewQuit(m.com)
2825 m.dialog.OpenDialog(quitDialog)
2826 return nil
2827}
2828
2829// openModelsDialog opens the models dialog.
2830func (m *UI) openModelsDialog() tea.Cmd {
2831 if m.dialog.ContainsDialog(dialog.ModelsID) {
2832 // Bring to front
2833 m.dialog.BringToFront(dialog.ModelsID)
2834 return nil
2835 }
2836
2837 isOnboarding := m.state == uiOnboarding
2838 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2839 if err != nil {
2840 return util.ReportError(err)
2841 }
2842
2843 m.dialog.OpenDialog(modelsDialog)
2844
2845 return nil
2846}
2847
2848// openCommandsDialog opens the commands dialog.
2849func (m *UI) openCommandsDialog() tea.Cmd {
2850 if m.dialog.ContainsDialog(dialog.CommandsID) {
2851 // Bring to front
2852 m.dialog.BringToFront(dialog.CommandsID)
2853 return nil
2854 }
2855
2856 sessionID := ""
2857 if m.session != nil {
2858 sessionID = m.session.ID
2859 }
2860
2861 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2862 if err != nil {
2863 return util.ReportError(err)
2864 }
2865
2866 m.dialog.OpenDialog(commands)
2867
2868 return nil
2869}
2870
2871// openReasoningDialog opens the reasoning effort dialog.
2872func (m *UI) openReasoningDialog() tea.Cmd {
2873 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2874 m.dialog.BringToFront(dialog.ReasoningID)
2875 return nil
2876 }
2877
2878 reasoningDialog, err := dialog.NewReasoning(m.com)
2879 if err != nil {
2880 return util.ReportError(err)
2881 }
2882
2883 m.dialog.OpenDialog(reasoningDialog)
2884 return nil
2885}
2886
2887// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2888// it brings it to the front. Otherwise, it will list all the sessions and open
2889// the dialog.
2890func (m *UI) openSessionsDialog() tea.Cmd {
2891 if m.dialog.ContainsDialog(dialog.SessionsID) {
2892 // Bring to front
2893 m.dialog.BringToFront(dialog.SessionsID)
2894 return nil
2895 }
2896
2897 selectedSessionID := ""
2898 if m.session != nil {
2899 selectedSessionID = m.session.ID
2900 }
2901
2902 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2903 if err != nil {
2904 return util.ReportError(err)
2905 }
2906
2907 m.dialog.OpenDialog(dialog)
2908 return nil
2909}
2910
2911// openFilesDialog opens the file picker dialog.
2912func (m *UI) openFilesDialog() tea.Cmd {
2913 if m.dialog.ContainsDialog(dialog.FilePickerID) {
2914 // Bring to front
2915 m.dialog.BringToFront(dialog.FilePickerID)
2916 return nil
2917 }
2918
2919 filePicker, cmd := dialog.NewFilePicker(m.com)
2920 filePicker.SetImageCapabilities(&m.caps)
2921 m.dialog.OpenDialog(filePicker)
2922
2923 return cmd
2924}
2925
2926// openPermissionsDialog opens the permissions dialog for a permission request.
2927func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2928 // Close any existing permissions dialog first.
2929 m.dialog.CloseDialog(dialog.PermissionsID)
2930
2931 // Get diff mode from config.
2932 var opts []dialog.PermissionsOption
2933 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2934 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2935 }
2936
2937 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2938 m.dialog.OpenDialog(permDialog)
2939 return nil
2940}
2941
2942// handlePermissionNotification updates tool items when permission state changes.
2943func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2944 toolItem := m.chat.MessageItem(notification.ToolCallID)
2945 if toolItem == nil {
2946 return
2947 }
2948
2949 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2950 if notification.Granted {
2951 permItem.SetStatus(chat.ToolStatusRunning)
2952 } else {
2953 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2954 }
2955 }
2956}
2957
2958// newSession clears the current session state and prepares for a new session.
2959// The actual session creation happens when the user sends their first message.
2960// Returns a command to reload prompt history.
2961func (m *UI) newSession() tea.Cmd {
2962 if !m.hasSession() {
2963 return nil
2964 }
2965
2966 m.session = nil
2967 m.sessionFiles = nil
2968 m.sessionFileReads = nil
2969 m.setState(uiLanding, uiFocusEditor)
2970 m.textarea.Focus()
2971 m.chat.Blur()
2972 m.chat.ClearMessages()
2973 m.pillsExpanded = false
2974 m.promptQueue = 0
2975 m.pillsView = ""
2976 m.historyReset()
2977 agenttools.ResetCache()
2978 return tea.Batch(
2979 func() tea.Msg {
2980 m.com.App.LSPManager.StopAll(context.Background())
2981 return nil
2982 },
2983 m.loadPromptHistory(),
2984 )
2985}
2986
2987// handlePasteMsg handles a paste message.
2988func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2989 if m.dialog.HasDialogs() {
2990 return m.handleDialogMsg(msg)
2991 }
2992
2993 if m.focus != uiFocusEditor {
2994 return nil
2995 }
2996
2997 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
2998 return func() tea.Msg {
2999 content := []byte(msg.Content)
3000 if int64(len(content)) > common.MaxAttachmentSize {
3001 return util.ReportWarn("Paste is too big (>5mb)")
3002 }
3003 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3004 mimeBufferSize := min(512, len(content))
3005 mimeType := http.DetectContentType(content[:mimeBufferSize])
3006 return message.Attachment{
3007 FileName: name,
3008 FilePath: name,
3009 MimeType: mimeType,
3010 Content: content,
3011 }
3012 }
3013 }
3014
3015 // Attempt to parse pasted content as file paths. If possible to parse,
3016 // all files exist and are valid, add as attachments.
3017 // Otherwise, paste as text.
3018 paths := fsext.ParsePastedFiles(msg.Content)
3019 allExistsAndValid := func() bool {
3020 if len(paths) == 0 {
3021 return false
3022 }
3023 for _, path := range paths {
3024 if _, err := os.Stat(path); os.IsNotExist(err) {
3025 return false
3026 }
3027
3028 lowerPath := strings.ToLower(path)
3029 isValid := false
3030 for _, ext := range common.AllowedImageTypes {
3031 if strings.HasSuffix(lowerPath, ext) {
3032 isValid = true
3033 break
3034 }
3035 }
3036 if !isValid {
3037 return false
3038 }
3039 }
3040 return true
3041 }
3042 if !allExistsAndValid() {
3043 var cmd tea.Cmd
3044 m.textarea, cmd = m.textarea.Update(msg)
3045 return cmd
3046 }
3047
3048 var cmds []tea.Cmd
3049 for _, path := range paths {
3050 cmds = append(cmds, m.handleFilePathPaste(path))
3051 }
3052 return tea.Batch(cmds...)
3053}
3054
3055// handleFilePathPaste handles a pasted file path.
3056func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3057 return func() tea.Msg {
3058 fileInfo, err := os.Stat(path)
3059 if err != nil {
3060 return util.ReportError(err)
3061 }
3062 if fileInfo.IsDir() {
3063 return util.ReportWarn("Cannot attach a directory")
3064 }
3065 if fileInfo.Size() > common.MaxAttachmentSize {
3066 return util.ReportWarn("File is too big (>5mb)")
3067 }
3068
3069 content, err := os.ReadFile(path)
3070 if err != nil {
3071 return util.ReportError(err)
3072 }
3073
3074 mimeBufferSize := min(512, len(content))
3075 mimeType := http.DetectContentType(content[:mimeBufferSize])
3076 fileName := filepath.Base(path)
3077 return message.Attachment{
3078 FilePath: path,
3079 FileName: fileName,
3080 MimeType: mimeType,
3081 Content: content,
3082 }
3083 }
3084}
3085
3086// pasteImageFromClipboard reads image data from the system clipboard and
3087// creates an attachment. If no image data is found, it falls back to
3088// interpreting clipboard text as a file path.
3089func (m *UI) pasteImageFromClipboard() tea.Msg {
3090 imageData, err := readClipboard(clipboardFormatImage)
3091 if int64(len(imageData)) > common.MaxAttachmentSize {
3092 return util.InfoMsg{
3093 Type: util.InfoTypeError,
3094 Msg: "File too large, max 5MB",
3095 }
3096 }
3097 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3098 if err == nil {
3099 return message.Attachment{
3100 FilePath: name,
3101 FileName: name,
3102 MimeType: mimeOf(imageData),
3103 Content: imageData,
3104 }
3105 }
3106
3107 textData, textErr := readClipboard(clipboardFormatText)
3108 if textErr != nil || len(textData) == 0 {
3109 return util.NewInfoMsg("Clipboard is empty or does not contain an image")
3110 }
3111
3112 path := strings.TrimSpace(string(textData))
3113 path = strings.ReplaceAll(path, "\\ ", " ")
3114 if _, statErr := os.Stat(path); statErr != nil {
3115 return util.NewInfoMsg("Clipboard does not contain an image or valid file path")
3116 }
3117
3118 lowerPath := strings.ToLower(path)
3119 isAllowed := false
3120 for _, ext := range common.AllowedImageTypes {
3121 if strings.HasSuffix(lowerPath, ext) {
3122 isAllowed = true
3123 break
3124 }
3125 }
3126 if !isAllowed {
3127 return util.NewInfoMsg("File type is not a supported image format")
3128 }
3129
3130 fileInfo, statErr := os.Stat(path)
3131 if statErr != nil {
3132 return util.InfoMsg{
3133 Type: util.InfoTypeError,
3134 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3135 }
3136 }
3137 if fileInfo.Size() > common.MaxAttachmentSize {
3138 return util.InfoMsg{
3139 Type: util.InfoTypeError,
3140 Msg: "File too large, max 5MB",
3141 }
3142 }
3143
3144 content, readErr := os.ReadFile(path)
3145 if readErr != nil {
3146 return util.InfoMsg{
3147 Type: util.InfoTypeError,
3148 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3149 }
3150 }
3151
3152 return message.Attachment{
3153 FilePath: path,
3154 FileName: filepath.Base(path),
3155 MimeType: mimeOf(content),
3156 Content: content,
3157 }
3158}
3159
3160var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3161
3162func (m *UI) pasteIdx() int {
3163 result := 0
3164 for _, at := range m.attachments.List() {
3165 found := pasteRE.FindStringSubmatch(at.FileName)
3166 if len(found) == 0 {
3167 continue
3168 }
3169 idx, err := strconv.Atoi(found[1])
3170 if err == nil {
3171 result = max(result, idx)
3172 }
3173 }
3174 return result + 1
3175}
3176
3177// drawSessionDetails draws the session details in compact mode.
3178func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3179 if m.session == nil {
3180 return
3181 }
3182
3183 s := m.com.Styles
3184
3185 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3186 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3187
3188 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3189 blocks := []string{
3190 title,
3191 "",
3192 m.modelInfo(width),
3193 "",
3194 }
3195
3196 detailsHeader := lipgloss.JoinVertical(
3197 lipgloss.Left,
3198 blocks...,
3199 )
3200
3201 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3202
3203 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3204
3205 const maxSectionWidth = 50
3206 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3207 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3208
3209 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3210 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3211 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3212 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3213 uv.NewStyledString(
3214 s.CompactDetails.View.
3215 Width(area.Dx()).
3216 Render(
3217 lipgloss.JoinVertical(
3218 lipgloss.Left,
3219 detailsHeader,
3220 sections,
3221 version,
3222 ),
3223 ),
3224 ).Draw(scr, area)
3225}
3226
3227func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3228 load := func() tea.Msg {
3229 prompt, err := commands.GetMCPPrompt(m.com.Config(), clientID, promptID, arguments)
3230 if err != nil {
3231 // TODO: make this better
3232 return util.ReportError(err)()
3233 }
3234
3235 if prompt == "" {
3236 return nil
3237 }
3238 return sendMessageMsg{
3239 Content: prompt,
3240 }
3241 }
3242
3243 var cmds []tea.Cmd
3244 if cmd := m.dialog.StartLoading(); cmd != nil {
3245 cmds = append(cmds, cmd)
3246 }
3247 cmds = append(cmds, load, func() tea.Msg {
3248 return closeDialogMsg{}
3249 })
3250
3251 return tea.Sequence(cmds...)
3252}
3253
3254func (m *UI) handleStateChanged() tea.Cmd {
3255 return func() tea.Msg {
3256 m.com.App.UpdateAgentModel(context.Background())
3257 return mcpStateChangedMsg{
3258 states: mcp.GetStates(),
3259 }
3260 }
3261}
3262
3263func handleMCPPromptsEvent(name string) tea.Cmd {
3264 return func() tea.Msg {
3265 mcp.RefreshPrompts(context.Background(), name)
3266 return nil
3267 }
3268}
3269
3270func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3271 return func() tea.Msg {
3272 mcp.RefreshTools(
3273 context.Background(),
3274 cfg,
3275 name,
3276 )
3277 return nil
3278 }
3279}
3280
3281func handleMCPResourcesEvent(name string) tea.Cmd {
3282 return func() tea.Msg {
3283 mcp.RefreshResources(context.Background(), name)
3284 return nil
3285 }
3286}
3287
3288func (m *UI) copyChatHighlight() tea.Cmd {
3289 text := m.chat.HighlightContent()
3290 return common.CopyToClipboardWithCallback(
3291 text,
3292 "Selected text copied to clipboard",
3293 func() tea.Msg {
3294 m.chat.ClearMouse()
3295 return nil
3296 },
3297 )
3298}
3299
3300// renderLogo renders the Crush logo with the given styles and dimensions.
3301func renderLogo(t *styles.Styles, compact bool, width int) string {
3302 return logo.Render(t, version.Version, compact, logo.Opts{
3303 FieldColor: t.LogoFieldColor,
3304 TitleColorA: t.LogoTitleColorA,
3305 TitleColorB: t.LogoTitleColorB,
3306 CharmColor: t.LogoCharmColor,
3307 VersionColor: t.LogoVersionColor,
3308 Width: width,
3309 })
3310}