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