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