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