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