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