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