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/commands"
32 "github.com/charmbracelet/crush/internal/config"
33 "github.com/charmbracelet/crush/internal/fsext"
34 "github.com/charmbracelet/crush/internal/history"
35 "github.com/charmbracelet/crush/internal/home"
36 "github.com/charmbracelet/crush/internal/message"
37 "github.com/charmbracelet/crush/internal/permission"
38 "github.com/charmbracelet/crush/internal/pubsub"
39 "github.com/charmbracelet/crush/internal/session"
40 "github.com/charmbracelet/crush/internal/ui/anim"
41 "github.com/charmbracelet/crush/internal/ui/attachments"
42 "github.com/charmbracelet/crush/internal/ui/chat"
43 "github.com/charmbracelet/crush/internal/ui/common"
44 "github.com/charmbracelet/crush/internal/ui/completions"
45 "github.com/charmbracelet/crush/internal/ui/dialog"
46 fimage "github.com/charmbracelet/crush/internal/ui/image"
47 "github.com/charmbracelet/crush/internal/ui/logo"
48 "github.com/charmbracelet/crush/internal/ui/notification"
49 "github.com/charmbracelet/crush/internal/ui/styles"
50 "github.com/charmbracelet/crush/internal/ui/util"
51 "github.com/charmbracelet/crush/internal/version"
52 "github.com/charmbracelet/crush/internal/workspace"
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]workspace.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]workspace.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, _ := com.Workspace.ProjectNeedsInitialization(); 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.Workspace.AgentQueuedPrompts(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.Workspace.ListMessages(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[workspace.LSPEvent]:
571 m.lspStates = m.com.Workspace.LSPGetStates()
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(m.com.Workspace, msg.Payload.Name)
581 case mcp.EventToolsListChanged:
582 return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
583 case mcp.EventResourcesListChanged:
584 return m, handleMCPResourcesEvent(m.com.Workspace, 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 if msg.Type == util.InfoTypeError {
795 slog.Error("Error reported", "error", msg.Msg)
796 }
797 m.status.SetInfoMsg(msg)
798 ttl := msg.TTL
799 if ttl <= 0 {
800 ttl = DefaultStatusTTL
801 }
802 cmds = append(cmds, clearInfoMsgCmd(ttl))
803 case util.ClearStatusMsg:
804 m.status.ClearInfoMsg()
805 case completions.CompletionItemsLoadedMsg:
806 if m.completionsOpen {
807 m.completions.SetItems(msg.Files, msg.Resources)
808 }
809 case uv.KittyGraphicsEvent:
810 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
811 slog.Warn("Unexpected Kitty graphics response",
812 "response", string(msg.Payload),
813 "options", msg.Options)
814 }
815 default:
816 if m.dialog.HasDialogs() {
817 if cmd := m.handleDialogMsg(msg); cmd != nil {
818 cmds = append(cmds, cmd)
819 }
820 }
821 }
822
823 // This logic gets triggered on any message type, but should it?
824 switch m.focus {
825 case uiFocusMain:
826 case uiFocusEditor:
827 // Textarea placeholder logic
828 if m.isAgentBusy() {
829 m.textarea.Placeholder = m.workingPlaceholder
830 } else {
831 m.textarea.Placeholder = m.readyPlaceholder
832 }
833 if m.com.Workspace.PermissionSkipRequests() {
834 m.textarea.Placeholder = "Yolo mode!"
835 }
836 }
837
838 // at this point this can only handle [message.Attachment] message, and we
839 // should return all cmds anyway.
840 _ = m.attachments.Update(msg)
841 return m, tea.Batch(cmds...)
842}
843
844// setSessionMessages sets the messages for the current session in the chat
845func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
846 var cmds []tea.Cmd
847 // Build tool result map to link tool calls with their results
848 msgPtrs := make([]*message.Message, len(msgs))
849 for i := range msgs {
850 msgPtrs[i] = &msgs[i]
851 }
852 toolResultMap := chat.BuildToolResultMap(msgPtrs)
853 if len(msgPtrs) > 0 {
854 m.lastUserMessageTime = msgPtrs[0].CreatedAt
855 }
856
857 // Add messages to chat with linked tool results
858 items := make([]chat.MessageItem, 0, len(msgs)*2)
859 for _, msg := range msgPtrs {
860 switch msg.Role {
861 case message.User:
862 m.lastUserMessageTime = msg.CreatedAt
863 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
864 case message.Assistant:
865 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
866 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
867 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
868 items = append(items, infoItem)
869 }
870 default:
871 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
872 }
873 }
874
875 // Load nested tool calls for agent/agentic_fetch tools.
876 m.loadNestedToolCalls(items)
877
878 // If the user switches between sessions while the agent is working we want
879 // to make sure the animations are shown.
880 for _, item := range items {
881 if animatable, ok := item.(chat.Animatable); ok {
882 if cmd := animatable.StartAnimation(); cmd != nil {
883 cmds = append(cmds, cmd)
884 }
885 }
886 }
887
888 m.chat.SetMessages(items...)
889 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
890 cmds = append(cmds, cmd)
891 }
892 m.chat.SelectLast()
893 return tea.Sequence(cmds...)
894}
895
896// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
897func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
898 for _, item := range items {
899 nestedContainer, ok := item.(chat.NestedToolContainer)
900 if !ok {
901 continue
902 }
903 toolItem, ok := item.(chat.ToolMessageItem)
904 if !ok {
905 continue
906 }
907
908 tc := toolItem.ToolCall()
909 messageID := toolItem.MessageID()
910
911 // Get the agent tool session ID.
912 agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
913
914 // Fetch nested messages.
915 nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
916 if err != nil || len(nestedMsgs) == 0 {
917 continue
918 }
919
920 // Build tool result map for nested messages.
921 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
922 for i := range nestedMsgs {
923 nestedMsgPtrs[i] = &nestedMsgs[i]
924 }
925 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
926
927 // Extract nested tool items.
928 var nestedTools []chat.ToolMessageItem
929 for _, nestedMsg := range nestedMsgPtrs {
930 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
931 for _, nestedItem := range nestedItems {
932 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
933 // Mark nested tools as simple (compact) rendering.
934 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
935 simplifiable.SetCompact(true)
936 }
937 nestedTools = append(nestedTools, nestedToolItem)
938 }
939 }
940 }
941
942 // Recursively load nested tool calls for any agent tools within.
943 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
944 for i, nt := range nestedTools {
945 nestedMessageItems[i] = nt
946 }
947 m.loadNestedToolCalls(nestedMessageItems)
948
949 // Set nested tools on the parent.
950 nestedContainer.SetNestedTools(nestedTools)
951 }
952}
953
954// appendSessionMessage appends a new message to the current session in the chat
955// if the message is a tool result it will update the corresponding tool call message
956func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
957 var cmds []tea.Cmd
958
959 existing := m.chat.MessageItem(msg.ID)
960 if existing != nil {
961 // message already exists, skip
962 return nil
963 }
964
965 switch msg.Role {
966 case message.User:
967 m.lastUserMessageTime = msg.CreatedAt
968 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
969 for _, item := range items {
970 if animatable, ok := item.(chat.Animatable); ok {
971 if cmd := animatable.StartAnimation(); cmd != nil {
972 cmds = append(cmds, cmd)
973 }
974 }
975 }
976 m.chat.AppendMessages(items...)
977 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
978 cmds = append(cmds, cmd)
979 }
980 case message.Assistant:
981 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
982 for _, item := range items {
983 if animatable, ok := item.(chat.Animatable); ok {
984 if cmd := animatable.StartAnimation(); cmd != nil {
985 cmds = append(cmds, cmd)
986 }
987 }
988 }
989 m.chat.AppendMessages(items...)
990 if m.chat.Follow() {
991 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
992 cmds = append(cmds, cmd)
993 }
994 }
995 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
996 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
997 m.chat.AppendMessages(infoItem)
998 if m.chat.Follow() {
999 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1000 cmds = append(cmds, cmd)
1001 }
1002 }
1003 }
1004 case message.Tool:
1005 for _, tr := range msg.ToolResults() {
1006 toolItem := m.chat.MessageItem(tr.ToolCallID)
1007 if toolItem == nil {
1008 // we should have an item!
1009 continue
1010 }
1011 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1012 toolMsgItem.SetResult(&tr)
1013 if m.chat.Follow() {
1014 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1015 cmds = append(cmds, cmd)
1016 }
1017 }
1018 }
1019 }
1020 }
1021 return tea.Sequence(cmds...)
1022}
1023
1024func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1025 switch {
1026 case m.state != uiChat:
1027 return nil
1028 case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1029 return nil
1030 case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1031 m.focus = uiFocusEditor
1032 cmd = m.textarea.Focus()
1033 m.chat.Blur()
1034 case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1035 m.focus = uiFocusMain
1036 m.textarea.Blur()
1037 m.chat.Focus()
1038 }
1039 return cmd
1040}
1041
1042// updateSessionMessage updates an existing message in the current session in the chat
1043// when an assistant message is updated it may include updated tool calls as well
1044// that is why we need to handle creating/updating each tool call message too
1045func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1046 var cmds []tea.Cmd
1047 existingItem := m.chat.MessageItem(msg.ID)
1048
1049 if existingItem != nil {
1050 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1051 assistantItem.SetMessage(&msg)
1052 }
1053 }
1054
1055 shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1056 // if the message of the assistant does not have any response just tool calls we need to remove it
1057 if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1058 m.chat.RemoveMessage(msg.ID)
1059 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1060 m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1061 }
1062 }
1063
1064 if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1065 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1066 newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1067 m.chat.AppendMessages(newInfoItem)
1068 }
1069 }
1070
1071 var items []chat.MessageItem
1072 for _, tc := range msg.ToolCalls() {
1073 existingToolItem := m.chat.MessageItem(tc.ID)
1074 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1075 existingToolCall := toolItem.ToolCall()
1076 // only update if finished state changed or input changed
1077 // to avoid clearing the cache
1078 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1079 toolItem.SetToolCall(tc)
1080 }
1081 }
1082 if existingToolItem == nil {
1083 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1084 }
1085 }
1086
1087 for _, item := range items {
1088 if animatable, ok := item.(chat.Animatable); ok {
1089 if cmd := animatable.StartAnimation(); cmd != nil {
1090 cmds = append(cmds, cmd)
1091 }
1092 }
1093 }
1094
1095 m.chat.AppendMessages(items...)
1096 if m.chat.Follow() {
1097 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1098 cmds = append(cmds, cmd)
1099 }
1100 m.chat.SelectLast()
1101 }
1102
1103 return tea.Sequence(cmds...)
1104}
1105
1106// handleChildSessionMessage handles messages from child sessions (agent tools).
1107func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1108 var cmds []tea.Cmd
1109
1110 // Only process messages with tool calls or results.
1111 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1112 return nil
1113 }
1114
1115 // Check if this is an agent tool session and parse it.
1116 childSessionID := event.Payload.SessionID
1117 _, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
1118 if !ok {
1119 return nil
1120 }
1121
1122 // Find the parent agent tool item.
1123 var agentItem chat.NestedToolContainer
1124 for i := 0; i < m.chat.Len(); i++ {
1125 item := m.chat.MessageItem(toolCallID)
1126 if item == nil {
1127 continue
1128 }
1129 if agent, ok := item.(chat.NestedToolContainer); ok {
1130 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1131 if toolMessageItem.ToolCall().ID == toolCallID {
1132 // Verify this agent belongs to the correct parent message.
1133 // We can't directly check parentMessageID on the item, so we trust the session parsing.
1134 agentItem = agent
1135 break
1136 }
1137 }
1138 }
1139 }
1140
1141 if agentItem == nil {
1142 return nil
1143 }
1144
1145 // Get existing nested tools.
1146 nestedTools := agentItem.NestedTools()
1147
1148 // Update or create nested tool calls.
1149 for _, tc := range event.Payload.ToolCalls() {
1150 found := false
1151 for _, existingTool := range nestedTools {
1152 if existingTool.ToolCall().ID == tc.ID {
1153 existingTool.SetToolCall(tc)
1154 found = true
1155 break
1156 }
1157 }
1158 if !found {
1159 // Create a new nested tool item.
1160 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1161 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1162 simplifiable.SetCompact(true)
1163 }
1164 if animatable, ok := nestedItem.(chat.Animatable); ok {
1165 if cmd := animatable.StartAnimation(); cmd != nil {
1166 cmds = append(cmds, cmd)
1167 }
1168 }
1169 nestedTools = append(nestedTools, nestedItem)
1170 }
1171 }
1172
1173 // Update nested tool results.
1174 for _, tr := range event.Payload.ToolResults() {
1175 for _, nestedTool := range nestedTools {
1176 if nestedTool.ToolCall().ID == tr.ToolCallID {
1177 nestedTool.SetResult(&tr)
1178 break
1179 }
1180 }
1181 }
1182
1183 // Update the agent item with the new nested tools.
1184 agentItem.SetNestedTools(nestedTools)
1185
1186 // Update the chat so it updates the index map for animations to work as expected
1187 m.chat.UpdateNestedToolIDs(toolCallID)
1188
1189 if m.chat.Follow() {
1190 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1191 cmds = append(cmds, cmd)
1192 }
1193 m.chat.SelectLast()
1194 }
1195
1196 return tea.Sequence(cmds...)
1197}
1198
1199func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1200 var cmds []tea.Cmd
1201 action := m.dialog.Update(msg)
1202 if action == nil {
1203 return tea.Batch(cmds...)
1204 }
1205
1206 isOnboarding := m.state == uiOnboarding
1207
1208 switch msg := action.(type) {
1209 // Generic dialog messages
1210 case dialog.ActionClose:
1211 if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1212 break
1213 }
1214
1215 if m.dialog.ContainsDialog(dialog.FilePickerID) {
1216 defer fimage.ResetCache()
1217 }
1218
1219 m.dialog.CloseFrontDialog()
1220
1221 if isOnboarding {
1222 if cmd := m.openModelsDialog(); cmd != nil {
1223 cmds = append(cmds, cmd)
1224 }
1225 }
1226
1227 if m.focus == uiFocusEditor {
1228 cmds = append(cmds, m.textarea.Focus())
1229 }
1230 case dialog.ActionCmd:
1231 if msg.Cmd != nil {
1232 cmds = append(cmds, msg.Cmd)
1233 }
1234
1235 // Session dialog messages
1236 case dialog.ActionSelectSession:
1237 m.dialog.CloseDialog(dialog.SessionsID)
1238 cmds = append(cmds, m.loadSession(msg.Session.ID))
1239
1240 // Open dialog message
1241 case dialog.ActionOpenDialog:
1242 m.dialog.CloseDialog(dialog.CommandsID)
1243 if cmd := m.openDialog(msg.DialogID); cmd != nil {
1244 cmds = append(cmds, cmd)
1245 }
1246
1247 // Command dialog messages
1248 case dialog.ActionToggleYoloMode:
1249 yolo := !m.com.Workspace.PermissionSkipRequests()
1250 m.com.Workspace.PermissionSetSkipRequests(yolo)
1251 m.setEditorPrompt(yolo)
1252 m.dialog.CloseDialog(dialog.CommandsID)
1253 case dialog.ActionNewSession:
1254 if m.isAgentBusy() {
1255 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1256 break
1257 }
1258 if cmd := m.newSession(); cmd != nil {
1259 cmds = append(cmds, cmd)
1260 }
1261 m.dialog.CloseDialog(dialog.CommandsID)
1262 case dialog.ActionSummarize:
1263 if m.isAgentBusy() {
1264 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1265 break
1266 }
1267 cmds = append(cmds, func() tea.Msg {
1268 err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1269 if err != nil {
1270 return util.ReportError(err)()
1271 }
1272 return nil
1273 })
1274 m.dialog.CloseDialog(dialog.CommandsID)
1275 case dialog.ActionToggleHelp:
1276 m.status.ToggleHelp()
1277 m.dialog.CloseDialog(dialog.CommandsID)
1278 case dialog.ActionExternalEditor:
1279 if m.isAgentBusy() {
1280 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1281 break
1282 }
1283 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1284 m.dialog.CloseDialog(dialog.CommandsID)
1285 case dialog.ActionToggleCompactMode:
1286 cmds = append(cmds, m.toggleCompactMode())
1287 m.dialog.CloseDialog(dialog.CommandsID)
1288 case dialog.ActionTogglePills:
1289 if cmd := m.togglePillsExpanded(); cmd != nil {
1290 cmds = append(cmds, cmd)
1291 }
1292 m.dialog.CloseDialog(dialog.CommandsID)
1293 case dialog.ActionToggleThinking:
1294 cmds = append(cmds, func() tea.Msg {
1295 cfg := m.com.Config()
1296 if cfg == nil {
1297 return util.ReportError(errors.New("configuration not found"))()
1298 }
1299
1300 agentCfg, ok := cfg.Agents[config.AgentCoder]
1301 if !ok {
1302 return util.ReportError(errors.New("agent configuration not found"))()
1303 }
1304
1305 currentModel := cfg.Models[agentCfg.Model]
1306 currentModel.Think = !currentModel.Think
1307 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1308 return util.ReportError(err)()
1309 }
1310 m.com.Workspace.UpdateAgentModel(context.TODO())
1311 status := "disabled"
1312 if currentModel.Think {
1313 status = "enabled"
1314 }
1315 return util.NewInfoMsg("Thinking mode " + status)
1316 })
1317 m.dialog.CloseDialog(dialog.CommandsID)
1318 case dialog.ActionQuit:
1319 cmds = append(cmds, tea.Quit)
1320 case dialog.ActionInitializeProject:
1321 if m.isAgentBusy() {
1322 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1323 break
1324 }
1325 cmds = append(cmds, m.initializeProject())
1326 m.dialog.CloseDialog(dialog.CommandsID)
1327
1328 case dialog.ActionSelectModel:
1329 if m.isAgentBusy() {
1330 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1331 break
1332 }
1333
1334 cfg := m.com.Config()
1335 if cfg == nil {
1336 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1337 break
1338 }
1339
1340 var (
1341 providerID = msg.Model.Provider
1342 isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1343 isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1344 )
1345
1346 // Attempt to import GitHub Copilot tokens from VSCode if available.
1347 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1348 m.com.Workspace.ImportCopilot()
1349 }
1350
1351 if !isConfigured() || msg.ReAuthenticate {
1352 m.dialog.CloseDialog(dialog.ModelsID)
1353 if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1354 cmds = append(cmds, cmd)
1355 }
1356 break
1357 }
1358
1359 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1360 cmds = append(cmds, util.ReportError(err))
1361 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1362 // Ensure small model is set is unset.
1363 smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1364 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1365 cmds = append(cmds, util.ReportError(err))
1366 }
1367 }
1368
1369 cmds = append(cmds, func() tea.Msg {
1370 if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1371 return util.ReportError(err)
1372 }
1373
1374 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1375
1376 return util.NewInfoMsg(modelMsg)
1377 })
1378
1379 m.dialog.CloseDialog(dialog.APIKeyInputID)
1380 m.dialog.CloseDialog(dialog.OAuthID)
1381 m.dialog.CloseDialog(dialog.ModelsID)
1382
1383 if isOnboarding {
1384 m.setState(uiLanding, uiFocusEditor)
1385 m.com.Config().SetupAgents()
1386 if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1387 cmds = append(cmds, util.ReportError(err))
1388 }
1389 }
1390 case dialog.ActionSelectReasoningEffort:
1391 if m.isAgentBusy() {
1392 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1393 break
1394 }
1395
1396 cfg := m.com.Config()
1397 if cfg == nil {
1398 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1399 break
1400 }
1401
1402 agentCfg, ok := cfg.Agents[config.AgentCoder]
1403 if !ok {
1404 cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1405 break
1406 }
1407
1408 currentModel := cfg.Models[agentCfg.Model]
1409 currentModel.ReasoningEffort = msg.Effort
1410 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1411 cmds = append(cmds, util.ReportError(err))
1412 break
1413 }
1414
1415 cmds = append(cmds, func() tea.Msg {
1416 m.com.Workspace.UpdateAgentModel(context.TODO())
1417 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1418 })
1419 m.dialog.CloseDialog(dialog.ReasoningID)
1420 case dialog.ActionPermissionResponse:
1421 m.dialog.CloseDialog(dialog.PermissionsID)
1422 switch msg.Action {
1423 case dialog.PermissionAllow:
1424 m.com.Workspace.PermissionGrant(msg.Permission)
1425 case dialog.PermissionAllowForSession:
1426 m.com.Workspace.PermissionGrantPersistent(msg.Permission)
1427 case dialog.PermissionDeny:
1428 m.com.Workspace.PermissionDeny(msg.Permission)
1429 }
1430
1431 case dialog.ActionFilePickerSelected:
1432 cmds = append(cmds, tea.Sequence(
1433 msg.Cmd(),
1434 func() tea.Msg {
1435 m.dialog.CloseDialog(dialog.FilePickerID)
1436 return nil
1437 },
1438 func() tea.Msg {
1439 fimage.ResetCache()
1440 return nil
1441 },
1442 ))
1443
1444 case dialog.ActionRunCustomCommand:
1445 if len(msg.Arguments) > 0 && msg.Args == nil {
1446 m.dialog.CloseFrontDialog()
1447 argsDialog := dialog.NewArguments(
1448 m.com,
1449 "Custom Command Arguments",
1450 "",
1451 msg.Arguments,
1452 msg, // Pass the action as the result
1453 )
1454 m.dialog.OpenDialog(argsDialog)
1455 break
1456 }
1457 content := msg.Content
1458 if msg.Args != nil {
1459 content = substituteArgs(content, msg.Args)
1460 }
1461 cmds = append(cmds, m.sendMessage(content))
1462 m.dialog.CloseFrontDialog()
1463 case dialog.ActionRunMCPPrompt:
1464 if len(msg.Arguments) > 0 && msg.Args == nil {
1465 m.dialog.CloseFrontDialog()
1466 title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1467 argsDialog := dialog.NewArguments(
1468 m.com,
1469 title,
1470 msg.Description,
1471 msg.Arguments,
1472 msg, // Pass the action as the result
1473 )
1474 m.dialog.OpenDialog(argsDialog)
1475 break
1476 }
1477 cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1478 default:
1479 cmds = append(cmds, util.CmdHandler(msg))
1480 }
1481
1482 return tea.Batch(cmds...)
1483}
1484
1485// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1486func substituteArgs(content string, args map[string]string) string {
1487 for name, value := range args {
1488 placeholder := "$" + name
1489 content = strings.ReplaceAll(content, placeholder, value)
1490 }
1491 return content
1492}
1493
1494func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1495 var (
1496 dlg dialog.Dialog
1497 cmd tea.Cmd
1498
1499 isOnboarding = m.state == uiOnboarding
1500 )
1501
1502 switch provider.ID {
1503 case "hyper":
1504 dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1505 case catwalk.InferenceProviderCopilot:
1506 dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1507 default:
1508 dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1509 }
1510
1511 if m.dialog.ContainsDialog(dlg.ID()) {
1512 m.dialog.BringToFront(dlg.ID())
1513 return nil
1514 }
1515
1516 m.dialog.OpenDialog(dlg)
1517 return cmd
1518}
1519
1520func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1521 var cmds []tea.Cmd
1522
1523 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1524 switch {
1525 case key.Matches(msg, m.keyMap.Help):
1526 m.status.ToggleHelp()
1527 m.updateLayoutAndSize()
1528 return true
1529 case key.Matches(msg, m.keyMap.Commands):
1530 if cmd := m.openCommandsDialog(); cmd != nil {
1531 cmds = append(cmds, cmd)
1532 }
1533 return true
1534 case key.Matches(msg, m.keyMap.Models):
1535 if cmd := m.openModelsDialog(); cmd != nil {
1536 cmds = append(cmds, cmd)
1537 }
1538 return true
1539 case key.Matches(msg, m.keyMap.Sessions):
1540 if cmd := m.openSessionsDialog(); cmd != nil {
1541 cmds = append(cmds, cmd)
1542 }
1543 return true
1544 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1545 m.detailsOpen = !m.detailsOpen
1546 m.updateLayoutAndSize()
1547 return true
1548 case key.Matches(msg, m.keyMap.Chat.TogglePills):
1549 if m.state == uiChat && m.hasSession() {
1550 if cmd := m.togglePillsExpanded(); cmd != nil {
1551 cmds = append(cmds, cmd)
1552 }
1553 return true
1554 }
1555 case key.Matches(msg, m.keyMap.Chat.PillLeft):
1556 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1557 if cmd := m.switchPillSection(-1); cmd != nil {
1558 cmds = append(cmds, cmd)
1559 }
1560 return true
1561 }
1562 case key.Matches(msg, m.keyMap.Chat.PillRight):
1563 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1564 if cmd := m.switchPillSection(1); cmd != nil {
1565 cmds = append(cmds, cmd)
1566 }
1567 return true
1568 }
1569 case key.Matches(msg, m.keyMap.Suspend):
1570 if m.isAgentBusy() {
1571 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1572 return true
1573 }
1574 cmds = append(cmds, tea.Suspend)
1575 return true
1576 }
1577 return false
1578 }
1579
1580 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1581 // Always handle quit keys first
1582 if cmd := m.openQuitDialog(); cmd != nil {
1583 cmds = append(cmds, cmd)
1584 }
1585
1586 return tea.Batch(cmds...)
1587 }
1588
1589 // Route all messages to dialog if one is open.
1590 if m.dialog.HasDialogs() {
1591 return m.handleDialogMsg(msg)
1592 }
1593
1594 // Handle cancel key when agent is busy.
1595 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1596 if m.isAgentBusy() {
1597 if cmd := m.cancelAgent(); cmd != nil {
1598 cmds = append(cmds, cmd)
1599 }
1600 return tea.Batch(cmds...)
1601 }
1602 }
1603
1604 switch m.state {
1605 case uiOnboarding:
1606 return tea.Batch(cmds...)
1607 case uiInitialize:
1608 cmds = append(cmds, m.updateInitializeView(msg)...)
1609 return tea.Batch(cmds...)
1610 case uiChat, uiLanding:
1611 switch m.focus {
1612 case uiFocusEditor:
1613 // Handle completions if open.
1614 if m.completionsOpen {
1615 if msg, ok := m.completions.Update(msg); ok {
1616 switch msg := msg.(type) {
1617 case completions.SelectionMsg[completions.FileCompletionValue]:
1618 cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1619 if !msg.KeepOpen {
1620 m.closeCompletions()
1621 }
1622 case completions.SelectionMsg[completions.ResourceCompletionValue]:
1623 cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1624 if !msg.KeepOpen {
1625 m.closeCompletions()
1626 }
1627 case completions.ClosedMsg:
1628 m.completionsOpen = false
1629 }
1630 return tea.Batch(cmds...)
1631 }
1632 }
1633
1634 if ok := m.attachments.Update(msg); ok {
1635 return tea.Batch(cmds...)
1636 }
1637
1638 switch {
1639 case key.Matches(msg, m.keyMap.Editor.AddImage):
1640 if cmd := m.openFilesDialog(); cmd != nil {
1641 cmds = append(cmds, cmd)
1642 }
1643
1644 case key.Matches(msg, m.keyMap.Editor.PasteImage):
1645 cmds = append(cmds, m.pasteImageFromClipboard)
1646
1647 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1648 value := m.textarea.Value()
1649 if before, ok := strings.CutSuffix(value, "\\"); ok {
1650 // If the last character is a backslash, remove it and add a newline.
1651 m.textarea.SetValue(before)
1652 break
1653 }
1654
1655 // Otherwise, send the message
1656 m.textarea.Reset()
1657
1658 value = strings.TrimSpace(value)
1659 if value == "exit" || value == "quit" {
1660 return m.openQuitDialog()
1661 }
1662
1663 attachments := m.attachments.List()
1664 m.attachments.Reset()
1665 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1666 return nil
1667 }
1668
1669 m.randomizePlaceholders()
1670 m.historyReset()
1671
1672 return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1673 case key.Matches(msg, m.keyMap.Chat.NewSession):
1674 if !m.hasSession() {
1675 break
1676 }
1677 if m.isAgentBusy() {
1678 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1679 break
1680 }
1681 if cmd := m.newSession(); cmd != nil {
1682 cmds = append(cmds, cmd)
1683 }
1684 case key.Matches(msg, m.keyMap.Tab):
1685 if m.state != uiLanding {
1686 m.setState(m.state, uiFocusMain)
1687 m.textarea.Blur()
1688 m.chat.Focus()
1689 m.chat.SetSelected(m.chat.Len() - 1)
1690 }
1691 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1692 if m.isAgentBusy() {
1693 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1694 break
1695 }
1696 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1697 case key.Matches(msg, m.keyMap.Editor.Newline):
1698 m.textarea.InsertRune('\n')
1699 m.closeCompletions()
1700 ta, cmd := m.textarea.Update(msg)
1701 m.textarea = ta
1702 cmds = append(cmds, cmd)
1703 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1704 cmd := m.handleHistoryUp(msg)
1705 if cmd != nil {
1706 cmds = append(cmds, cmd)
1707 }
1708 case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1709 cmd := m.handleHistoryDown(msg)
1710 if cmd != nil {
1711 cmds = append(cmds, cmd)
1712 }
1713 case key.Matches(msg, m.keyMap.Editor.Escape):
1714 cmd := m.handleHistoryEscape(msg)
1715 if cmd != nil {
1716 cmds = append(cmds, cmd)
1717 }
1718 case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1719 if cmd := m.openCommandsDialog(); cmd != nil {
1720 cmds = append(cmds, cmd)
1721 }
1722 default:
1723 if handleGlobalKeys(msg) {
1724 // Handle global keys first before passing to textarea.
1725 break
1726 }
1727
1728 // Check for @ trigger before passing to textarea.
1729 curValue := m.textarea.Value()
1730 curIdx := len(curValue)
1731
1732 // Trigger completions on @.
1733 if msg.String() == "@" && !m.completionsOpen {
1734 // Only show if beginning of prompt or after whitespace.
1735 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1736 m.completionsOpen = true
1737 m.completionsQuery = ""
1738 m.completionsStartIndex = curIdx
1739 m.completionsPositionStart = m.completionsPosition()
1740 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1741 cmds = append(cmds, m.completions.Open(depth, limit))
1742 }
1743 }
1744
1745 // remove the details if they are open when user starts typing
1746 if m.detailsOpen {
1747 m.detailsOpen = false
1748 m.updateLayoutAndSize()
1749 }
1750
1751 ta, cmd := m.textarea.Update(msg)
1752 m.textarea = ta
1753 cmds = append(cmds, cmd)
1754
1755 // Any text modification becomes the current draft.
1756 m.updateHistoryDraft(curValue)
1757
1758 // After updating textarea, check if we need to filter completions.
1759 // Skip filtering on the initial @ keystroke since items are loading async.
1760 if m.completionsOpen && msg.String() != "@" {
1761 newValue := m.textarea.Value()
1762 newIdx := len(newValue)
1763
1764 // Close completions if cursor moved before start.
1765 if newIdx <= m.completionsStartIndex {
1766 m.closeCompletions()
1767 } else if msg.String() == "space" {
1768 // Close on space.
1769 m.closeCompletions()
1770 } else {
1771 // Extract current word and filter.
1772 word := m.textareaWord()
1773 if strings.HasPrefix(word, "@") {
1774 m.completionsQuery = word[1:]
1775 m.completions.Filter(m.completionsQuery)
1776 } else if m.completionsOpen {
1777 m.closeCompletions()
1778 }
1779 }
1780 }
1781 }
1782 case uiFocusMain:
1783 switch {
1784 case key.Matches(msg, m.keyMap.Tab):
1785 m.focus = uiFocusEditor
1786 cmds = append(cmds, m.textarea.Focus())
1787 m.chat.Blur()
1788 case key.Matches(msg, m.keyMap.Chat.NewSession):
1789 if !m.hasSession() {
1790 break
1791 }
1792 if m.isAgentBusy() {
1793 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1794 break
1795 }
1796 m.focus = uiFocusEditor
1797 if cmd := m.newSession(); cmd != nil {
1798 cmds = append(cmds, cmd)
1799 }
1800 case key.Matches(msg, m.keyMap.Chat.Expand):
1801 m.chat.ToggleExpandedSelectedItem()
1802 case key.Matches(msg, m.keyMap.Chat.Up):
1803 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1804 cmds = append(cmds, cmd)
1805 }
1806 if !m.chat.SelectedItemInView() {
1807 m.chat.SelectPrev()
1808 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1809 cmds = append(cmds, cmd)
1810 }
1811 }
1812 case key.Matches(msg, m.keyMap.Chat.Down):
1813 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1814 cmds = append(cmds, cmd)
1815 }
1816 if !m.chat.SelectedItemInView() {
1817 m.chat.SelectNext()
1818 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1819 cmds = append(cmds, cmd)
1820 }
1821 }
1822 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1823 m.chat.SelectPrev()
1824 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1825 cmds = append(cmds, cmd)
1826 }
1827 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1828 m.chat.SelectNext()
1829 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1830 cmds = append(cmds, cmd)
1831 }
1832 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1833 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1834 cmds = append(cmds, cmd)
1835 }
1836 m.chat.SelectFirstInView()
1837 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1838 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1839 cmds = append(cmds, cmd)
1840 }
1841 m.chat.SelectLastInView()
1842 case key.Matches(msg, m.keyMap.Chat.PageUp):
1843 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1844 cmds = append(cmds, cmd)
1845 }
1846 m.chat.SelectFirstInView()
1847 case key.Matches(msg, m.keyMap.Chat.PageDown):
1848 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1849 cmds = append(cmds, cmd)
1850 }
1851 m.chat.SelectLastInView()
1852 case key.Matches(msg, m.keyMap.Chat.Home):
1853 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1854 cmds = append(cmds, cmd)
1855 }
1856 m.chat.SelectFirst()
1857 case key.Matches(msg, m.keyMap.Chat.End):
1858 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1859 cmds = append(cmds, cmd)
1860 }
1861 m.chat.SelectLast()
1862 default:
1863 if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1864 cmds = append(cmds, cmd)
1865 } else {
1866 handleGlobalKeys(msg)
1867 }
1868 }
1869 default:
1870 handleGlobalKeys(msg)
1871 }
1872 default:
1873 handleGlobalKeys(msg)
1874 }
1875
1876 return tea.Sequence(cmds...)
1877}
1878
1879// drawHeader draws the header section of the UI.
1880func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1881 m.header.drawHeader(
1882 scr,
1883 area,
1884 m.session,
1885 m.isCompact,
1886 m.detailsOpen,
1887 area.Dx(),
1888 )
1889}
1890
1891// Draw implements [uv.Drawable] and draws the UI model.
1892func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1893 layout := m.generateLayout(area.Dx(), area.Dy())
1894
1895 if m.layout != layout {
1896 m.layout = layout
1897 m.updateSize()
1898 }
1899
1900 // Clear the screen first
1901 screen.Clear(scr)
1902
1903 switch m.state {
1904 case uiOnboarding:
1905 m.drawHeader(scr, layout.header)
1906
1907 // NOTE: Onboarding flow will be rendered as dialogs below, but
1908 // positioned at the bottom left of the screen.
1909
1910 case uiInitialize:
1911 m.drawHeader(scr, layout.header)
1912
1913 main := uv.NewStyledString(m.initializeView())
1914 main.Draw(scr, layout.main)
1915
1916 case uiLanding:
1917 m.drawHeader(scr, layout.header)
1918 main := uv.NewStyledString(m.landingView())
1919 main.Draw(scr, layout.main)
1920
1921 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1922 editor.Draw(scr, layout.editor)
1923
1924 case uiChat:
1925 if m.isCompact {
1926 m.drawHeader(scr, layout.header)
1927 } else {
1928 m.drawSidebar(scr, layout.sidebar)
1929 }
1930
1931 m.chat.Draw(scr, layout.main)
1932 if layout.pills.Dy() > 0 && m.pillsView != "" {
1933 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1934 }
1935
1936 editorWidth := scr.Bounds().Dx()
1937 if !m.isCompact {
1938 editorWidth -= layout.sidebar.Dx()
1939 }
1940 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1941 editor.Draw(scr, layout.editor)
1942
1943 // Draw details overlay in compact mode when open
1944 if m.isCompact && m.detailsOpen {
1945 m.drawSessionDetails(scr, layout.sessionDetails)
1946 }
1947 }
1948
1949 isOnboarding := m.state == uiOnboarding
1950
1951 // Add status and help layer
1952 m.status.SetHideHelp(isOnboarding)
1953 m.status.Draw(scr, layout.status)
1954
1955 // Draw completions popup if open
1956 if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1957 w, h := m.completions.Size()
1958 x := m.completionsPositionStart.X
1959 y := m.completionsPositionStart.Y - h
1960
1961 screenW := area.Dx()
1962 if x+w > screenW {
1963 x = screenW - w
1964 }
1965 x = max(0, x)
1966 y = max(0, y+1) // Offset for attachments row
1967
1968 completionsView := uv.NewStyledString(m.completions.Render())
1969 completionsView.Draw(scr, image.Rectangle{
1970 Min: image.Pt(x, y),
1971 Max: image.Pt(x+w, y+h),
1972 })
1973 }
1974
1975 // Debugging rendering (visually see when the tui rerenders)
1976 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1977 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1978 debug := uv.NewStyledString(debugView.String())
1979 debug.Draw(scr, image.Rectangle{
1980 Min: image.Pt(4, 1),
1981 Max: image.Pt(8, 3),
1982 })
1983 }
1984
1985 // This needs to come last to overlay on top of everything. We always pass
1986 // the full screen bounds because the dialogs will position themselves
1987 // accordingly.
1988 if m.dialog.HasDialogs() {
1989 return m.dialog.Draw(scr, scr.Bounds())
1990 }
1991
1992 switch m.focus {
1993 case uiFocusEditor:
1994 if m.layout.editor.Dy() <= 0 {
1995 // Don't show cursor if editor is not visible
1996 return nil
1997 }
1998 if m.detailsOpen && m.isCompact {
1999 // Don't show cursor if details overlay is open
2000 return nil
2001 }
2002
2003 if m.textarea.Focused() {
2004 cur := m.textarea.Cursor()
2005 cur.X++ // Adjust for app margins
2006 cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2007 return cur
2008 }
2009 }
2010 return nil
2011}
2012
2013// View renders the UI model's view.
2014func (m *UI) View() tea.View {
2015 var v tea.View
2016 v.AltScreen = true
2017 if !m.isTransparent {
2018 v.BackgroundColor = m.com.Styles.Background
2019 }
2020 v.MouseMode = tea.MouseModeCellMotion
2021 v.ReportFocus = m.caps.ReportFocusEvents
2022 v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2023
2024 canvas := uv.NewScreenBuffer(m.width, m.height)
2025 v.Cursor = m.Draw(canvas, canvas.Bounds())
2026
2027 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2028 contentLines := strings.Split(content, "\n")
2029 for i, line := range contentLines {
2030 // Trim trailing spaces for concise rendering
2031 contentLines[i] = strings.TrimRight(line, " ")
2032 }
2033
2034 content = strings.Join(contentLines, "\n")
2035
2036 v.Content = content
2037 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2038 // HACK: use a random percentage to prevent ghostty from hiding it
2039 // after a timeout.
2040 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2041 }
2042
2043 return v
2044}
2045
2046// ShortHelp implements [help.KeyMap].
2047func (m *UI) ShortHelp() []key.Binding {
2048 var binds []key.Binding
2049 k := &m.keyMap
2050 tab := k.Tab
2051 commands := k.Commands
2052 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2053 commands.SetHelp("/ or ctrl+p", "commands")
2054 }
2055
2056 switch m.state {
2057 case uiInitialize:
2058 binds = append(binds, k.Quit)
2059 case uiChat:
2060 // Show cancel binding if agent is busy.
2061 if m.isAgentBusy() {
2062 cancelBinding := k.Chat.Cancel
2063 if m.isCanceling {
2064 cancelBinding.SetHelp("esc", "press again to cancel")
2065 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2066 cancelBinding.SetHelp("esc", "clear queue")
2067 }
2068 binds = append(binds, cancelBinding)
2069 }
2070
2071 if m.focus == uiFocusEditor {
2072 tab.SetHelp("tab", "focus chat")
2073 } else {
2074 tab.SetHelp("tab", "focus editor")
2075 }
2076
2077 binds = append(binds,
2078 tab,
2079 commands,
2080 k.Models,
2081 )
2082
2083 switch m.focus {
2084 case uiFocusEditor:
2085 binds = append(binds,
2086 k.Editor.Newline,
2087 )
2088 case uiFocusMain:
2089 binds = append(binds,
2090 k.Chat.UpDown,
2091 k.Chat.UpDownOneItem,
2092 k.Chat.PageUp,
2093 k.Chat.PageDown,
2094 k.Chat.Copy,
2095 )
2096 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2097 binds = append(binds, k.Chat.PillLeft)
2098 }
2099 }
2100 default:
2101 // TODO: other states
2102 // if m.session == nil {
2103 // no session selected
2104 binds = append(binds,
2105 commands,
2106 k.Models,
2107 k.Editor.Newline,
2108 )
2109 }
2110
2111 binds = append(binds,
2112 k.Quit,
2113 k.Help,
2114 )
2115
2116 return binds
2117}
2118
2119// FullHelp implements [help.KeyMap].
2120func (m *UI) FullHelp() [][]key.Binding {
2121 var binds [][]key.Binding
2122 k := &m.keyMap
2123 help := k.Help
2124 help.SetHelp("ctrl+g", "less")
2125 hasAttachments := len(m.attachments.List()) > 0
2126 hasSession := m.hasSession()
2127 commands := k.Commands
2128 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2129 commands.SetHelp("/ or ctrl+p", "commands")
2130 }
2131
2132 switch m.state {
2133 case uiInitialize:
2134 binds = append(binds,
2135 []key.Binding{
2136 k.Quit,
2137 })
2138 case uiChat:
2139 // Show cancel binding if agent is busy.
2140 if m.isAgentBusy() {
2141 cancelBinding := k.Chat.Cancel
2142 if m.isCanceling {
2143 cancelBinding.SetHelp("esc", "press again to cancel")
2144 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2145 cancelBinding.SetHelp("esc", "clear queue")
2146 }
2147 binds = append(binds, []key.Binding{cancelBinding})
2148 }
2149
2150 mainBinds := []key.Binding{}
2151 tab := k.Tab
2152 if m.focus == uiFocusEditor {
2153 tab.SetHelp("tab", "focus chat")
2154 } else {
2155 tab.SetHelp("tab", "focus editor")
2156 }
2157
2158 mainBinds = append(mainBinds,
2159 tab,
2160 commands,
2161 k.Models,
2162 k.Sessions,
2163 )
2164 if hasSession {
2165 mainBinds = append(mainBinds, k.Chat.NewSession)
2166 }
2167
2168 binds = append(binds, mainBinds)
2169
2170 switch m.focus {
2171 case uiFocusEditor:
2172 binds = append(binds,
2173 []key.Binding{
2174 k.Editor.Newline,
2175 k.Editor.AddImage,
2176 k.Editor.PasteImage,
2177 k.Editor.MentionFile,
2178 k.Editor.OpenEditor,
2179 },
2180 )
2181 if hasAttachments {
2182 binds = append(binds,
2183 []key.Binding{
2184 k.Editor.AttachmentDeleteMode,
2185 k.Editor.DeleteAllAttachments,
2186 k.Editor.Escape,
2187 },
2188 )
2189 }
2190 case uiFocusMain:
2191 binds = append(binds,
2192 []key.Binding{
2193 k.Chat.UpDown,
2194 k.Chat.UpDownOneItem,
2195 k.Chat.PageUp,
2196 k.Chat.PageDown,
2197 },
2198 []key.Binding{
2199 k.Chat.HalfPageUp,
2200 k.Chat.HalfPageDown,
2201 k.Chat.Home,
2202 k.Chat.End,
2203 },
2204 []key.Binding{
2205 k.Chat.Copy,
2206 k.Chat.ClearHighlight,
2207 },
2208 )
2209 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2210 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2211 }
2212 }
2213 default:
2214 if m.session == nil {
2215 // no session selected
2216 binds = append(binds,
2217 []key.Binding{
2218 commands,
2219 k.Models,
2220 k.Sessions,
2221 },
2222 []key.Binding{
2223 k.Editor.Newline,
2224 k.Editor.AddImage,
2225 k.Editor.PasteImage,
2226 k.Editor.MentionFile,
2227 k.Editor.OpenEditor,
2228 },
2229 )
2230 if hasAttachments {
2231 binds = append(binds,
2232 []key.Binding{
2233 k.Editor.AttachmentDeleteMode,
2234 k.Editor.DeleteAllAttachments,
2235 k.Editor.Escape,
2236 },
2237 )
2238 }
2239 binds = append(binds,
2240 []key.Binding{
2241 help,
2242 },
2243 )
2244 }
2245 }
2246
2247 binds = append(binds,
2248 []key.Binding{
2249 help,
2250 k.Quit,
2251 },
2252 )
2253
2254 return binds
2255}
2256
2257// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2258func (m *UI) toggleCompactMode() tea.Cmd {
2259 m.forceCompactMode = !m.forceCompactMode
2260
2261 err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2262 if err != nil {
2263 return util.ReportError(err)
2264 }
2265
2266 m.updateLayoutAndSize()
2267
2268 return nil
2269}
2270
2271// updateLayoutAndSize updates the layout and sizes of UI components.
2272func (m *UI) updateLayoutAndSize() {
2273 // Determine if we should be in compact mode
2274 if m.state == uiChat {
2275 if m.forceCompactMode {
2276 m.isCompact = true
2277 return
2278 }
2279 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2280 m.isCompact = true
2281 } else {
2282 m.isCompact = false
2283 }
2284 }
2285
2286 m.layout = m.generateLayout(m.width, m.height)
2287 m.updateSize()
2288}
2289
2290// updateSize updates the sizes of UI components based on the current layout.
2291func (m *UI) updateSize() {
2292 // Set status width
2293 m.status.SetWidth(m.layout.status.Dx())
2294
2295 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2296 m.textarea.SetWidth(m.layout.editor.Dx())
2297 // TODO: Abstract the textarea and attachments into a single editor
2298 // component so we don't have to manually account for the attachments
2299 // height here.
2300 m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2301 m.renderPills()
2302
2303 // Handle different app states
2304 switch m.state {
2305 case uiChat:
2306 if !m.isCompact {
2307 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2308 }
2309 }
2310}
2311
2312// generateLayout calculates the layout rectangles for all UI components based
2313// on the current UI state and terminal dimensions.
2314func (m *UI) generateLayout(w, h int) uiLayout {
2315 // The screen area we're working with
2316 area := image.Rect(0, 0, w, h)
2317
2318 // The help height
2319 helpHeight := 1
2320 // The editor height
2321 editorHeight := 5
2322 // The sidebar width
2323 sidebarWidth := 30
2324 // The header height
2325 const landingHeaderHeight = 4
2326
2327 var helpKeyMap help.KeyMap = m
2328 if m.status != nil && m.status.ShowingAll() {
2329 for _, row := range helpKeyMap.FullHelp() {
2330 helpHeight = max(helpHeight, len(row))
2331 }
2332 }
2333
2334 // Add app margins
2335 appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2336 appRect.Min.Y += 1
2337 appRect.Max.Y -= 1
2338 helpRect.Min.Y -= 1
2339 appRect.Min.X += 1
2340 appRect.Max.X -= 1
2341
2342 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2343 // extra padding on left and right for these states
2344 appRect.Min.X += 1
2345 appRect.Max.X -= 1
2346 }
2347
2348 uiLayout := uiLayout{
2349 area: area,
2350 status: helpRect,
2351 }
2352
2353 // Handle different app states
2354 switch m.state {
2355 case uiOnboarding, uiInitialize:
2356 // Layout
2357 //
2358 // header
2359 // ------
2360 // main
2361 // ------
2362 // help
2363
2364 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2365 uiLayout.header = headerRect
2366 uiLayout.main = mainRect
2367
2368 case uiLanding:
2369 // Layout
2370 //
2371 // header
2372 // ------
2373 // main
2374 // ------
2375 // editor
2376 // ------
2377 // help
2378 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2379 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2380 // Remove extra padding from editor (but keep it for header and main)
2381 editorRect.Min.X -= 1
2382 editorRect.Max.X += 1
2383 uiLayout.header = headerRect
2384 uiLayout.main = mainRect
2385 uiLayout.editor = editorRect
2386
2387 case uiChat:
2388 if m.isCompact {
2389 // Layout
2390 //
2391 // compact-header
2392 // ------
2393 // main
2394 // ------
2395 // editor
2396 // ------
2397 // help
2398 const compactHeaderHeight = 1
2399 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2400 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2401 sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2402 uiLayout.sessionDetails = sessionDetailsArea
2403 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2404 // Add one line gap between header and main content
2405 mainRect.Min.Y += 1
2406 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2407 mainRect.Max.X -= 1 // Add padding right
2408 uiLayout.header = headerRect
2409 pillsHeight := m.pillsAreaHeight()
2410 if pillsHeight > 0 {
2411 pillsHeight = min(pillsHeight, mainRect.Dy())
2412 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2413 uiLayout.main = chatRect
2414 uiLayout.pills = pillsRect
2415 } else {
2416 uiLayout.main = mainRect
2417 }
2418 // Add bottom margin to main
2419 uiLayout.main.Max.Y -= 1
2420 uiLayout.editor = editorRect
2421 } else {
2422 // Layout
2423 //
2424 // ------|---
2425 // main |
2426 // ------| side
2427 // editor|
2428 // ----------
2429 // help
2430
2431 mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2432 // Add padding left
2433 sideRect.Min.X += 1
2434 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2435 mainRect.Max.X -= 1 // Add padding right
2436 uiLayout.sidebar = sideRect
2437 pillsHeight := m.pillsAreaHeight()
2438 if pillsHeight > 0 {
2439 pillsHeight = min(pillsHeight, mainRect.Dy())
2440 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2441 uiLayout.main = chatRect
2442 uiLayout.pills = pillsRect
2443 } else {
2444 uiLayout.main = mainRect
2445 }
2446 // Add bottom margin to main
2447 uiLayout.main.Max.Y -= 1
2448 uiLayout.editor = editorRect
2449 }
2450 }
2451
2452 return uiLayout
2453}
2454
2455// uiLayout defines the positioning of UI elements.
2456type uiLayout struct {
2457 // area is the overall available area.
2458 area uv.Rectangle
2459
2460 // header is the header shown in special cases
2461 // e.x when the sidebar is collapsed
2462 // or when in the landing page
2463 // or in init/config
2464 header uv.Rectangle
2465
2466 // main is the area for the main pane. (e.x chat, configure, landing)
2467 main uv.Rectangle
2468
2469 // pills is the area for the pills panel.
2470 pills uv.Rectangle
2471
2472 // editor is the area for the editor pane.
2473 editor uv.Rectangle
2474
2475 // sidebar is the area for the sidebar.
2476 sidebar uv.Rectangle
2477
2478 // status is the area for the status view.
2479 status uv.Rectangle
2480
2481 // session details is the area for the session details overlay in compact mode.
2482 sessionDetails uv.Rectangle
2483}
2484
2485func (m *UI) openEditor(value string) tea.Cmd {
2486 tmpfile, err := os.CreateTemp("", "msg_*.md")
2487 if err != nil {
2488 return util.ReportError(err)
2489 }
2490 defer tmpfile.Close() //nolint:errcheck
2491 if _, err := tmpfile.WriteString(value); err != nil {
2492 return util.ReportError(err)
2493 }
2494 cmd, err := editor.Command(
2495 "crush",
2496 tmpfile.Name(),
2497 editor.AtPosition(
2498 m.textarea.Line()+1,
2499 m.textarea.Column()+1,
2500 ),
2501 )
2502 if err != nil {
2503 return util.ReportError(err)
2504 }
2505 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2506 if err != nil {
2507 return util.ReportError(err)
2508 }
2509 content, err := os.ReadFile(tmpfile.Name())
2510 if err != nil {
2511 return util.ReportError(err)
2512 }
2513 if len(content) == 0 {
2514 return util.ReportWarn("Message is empty")
2515 }
2516 os.Remove(tmpfile.Name())
2517 return openEditorMsg{
2518 Text: strings.TrimSpace(string(content)),
2519 }
2520 })
2521}
2522
2523// setEditorPrompt configures the textarea prompt function based on whether
2524// yolo mode is enabled.
2525func (m *UI) setEditorPrompt(yolo bool) {
2526 if yolo {
2527 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2528 return
2529 }
2530 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2531}
2532
2533// normalPromptFunc returns the normal editor prompt style (" > " on first
2534// line, "::: " on subsequent lines).
2535func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2536 t := m.com.Styles
2537 if info.LineNumber == 0 {
2538 if info.Focused {
2539 return " > "
2540 }
2541 return "::: "
2542 }
2543 if info.Focused {
2544 return t.EditorPromptNormalFocused.Render()
2545 }
2546 return t.EditorPromptNormalBlurred.Render()
2547}
2548
2549// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2550// and colored dots.
2551func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2552 t := m.com.Styles
2553 if info.LineNumber == 0 {
2554 if info.Focused {
2555 return t.EditorPromptYoloIconFocused.Render()
2556 } else {
2557 return t.EditorPromptYoloIconBlurred.Render()
2558 }
2559 }
2560 if info.Focused {
2561 return t.EditorPromptYoloDotsFocused.Render()
2562 }
2563 return t.EditorPromptYoloDotsBlurred.Render()
2564}
2565
2566// closeCompletions closes the completions popup and resets state.
2567func (m *UI) closeCompletions() {
2568 m.completionsOpen = false
2569 m.completionsQuery = ""
2570 m.completionsStartIndex = 0
2571 m.completions.Close()
2572}
2573
2574// insertCompletionText replaces the @query in the textarea with the given text.
2575// Returns false if the replacement cannot be performed.
2576func (m *UI) insertCompletionText(text string) bool {
2577 value := m.textarea.Value()
2578 if m.completionsStartIndex > len(value) {
2579 return false
2580 }
2581
2582 word := m.textareaWord()
2583 endIdx := min(m.completionsStartIndex+len(word), len(value))
2584 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2585 m.textarea.SetValue(newValue)
2586 m.textarea.MoveToEnd()
2587 m.textarea.InsertRune(' ')
2588 return true
2589}
2590
2591// insertFileCompletion inserts the selected file path into the textarea,
2592// replacing the @query, and adds the file as an attachment.
2593func (m *UI) insertFileCompletion(path string) tea.Cmd {
2594 if !m.insertCompletionText(path) {
2595 return nil
2596 }
2597
2598 return func() tea.Msg {
2599 absPath, _ := filepath.Abs(path)
2600
2601 if m.hasSession() {
2602 // Skip attachment if file was already read and hasn't been modified.
2603 lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2604 if !lastRead.IsZero() {
2605 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2606 return nil
2607 }
2608 }
2609 } else if slices.Contains(m.sessionFileReads, absPath) {
2610 return nil
2611 }
2612
2613 m.sessionFileReads = append(m.sessionFileReads, absPath)
2614
2615 // Add file as attachment.
2616 content, err := os.ReadFile(path)
2617 if err != nil {
2618 // If it fails, let the LLM handle it later.
2619 return nil
2620 }
2621
2622 return message.Attachment{
2623 FilePath: path,
2624 FileName: filepath.Base(path),
2625 MimeType: mimeOf(content),
2626 Content: content,
2627 }
2628 }
2629}
2630
2631// insertMCPResourceCompletion inserts the selected resource into the textarea,
2632// replacing the @query, and adds the resource as an attachment.
2633func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2634 displayText := cmp.Or(item.Title, item.URI)
2635
2636 if !m.insertCompletionText(displayText) {
2637 return nil
2638 }
2639
2640 return func() tea.Msg {
2641 contents, err := m.com.Workspace.ReadMCPResource(
2642 context.Background(),
2643 item.MCPName,
2644 item.URI,
2645 )
2646 if err != nil {
2647 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2648 return nil
2649 }
2650 if len(contents) == 0 {
2651 return nil
2652 }
2653
2654 content := contents[0]
2655 var data []byte
2656 if content.Text != "" {
2657 data = []byte(content.Text)
2658 } else if len(content.Blob) > 0 {
2659 data = content.Blob
2660 }
2661 if len(data) == 0 {
2662 return nil
2663 }
2664
2665 mimeType := item.MIMEType
2666 if mimeType == "" && content.MIMEType != "" {
2667 mimeType = content.MIMEType
2668 }
2669 if mimeType == "" {
2670 mimeType = "text/plain"
2671 }
2672
2673 return message.Attachment{
2674 FilePath: item.URI,
2675 FileName: displayText,
2676 MimeType: mimeType,
2677 Content: data,
2678 }
2679 }
2680}
2681
2682// completionsPosition returns the X and Y position for the completions popup.
2683func (m *UI) completionsPosition() image.Point {
2684 cur := m.textarea.Cursor()
2685 if cur == nil {
2686 return image.Point{
2687 X: m.layout.editor.Min.X,
2688 Y: m.layout.editor.Min.Y,
2689 }
2690 }
2691 return image.Point{
2692 X: cur.X + m.layout.editor.Min.X,
2693 Y: m.layout.editor.Min.Y + cur.Y,
2694 }
2695}
2696
2697// textareaWord returns the current word at the cursor position.
2698func (m *UI) textareaWord() string {
2699 return m.textarea.Word()
2700}
2701
2702// isWhitespace returns true if the byte is a whitespace character.
2703func isWhitespace(b byte) bool {
2704 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2705}
2706
2707// isAgentBusy returns true if the agent coordinator exists and is currently
2708// busy processing a request.
2709func (m *UI) isAgentBusy() bool {
2710 return m.com.Workspace.AgentIsReady() &&
2711 m.com.Workspace.AgentIsBusy()
2712}
2713
2714// hasSession returns true if there is an active session with a valid ID.
2715func (m *UI) hasSession() bool {
2716 return m.session != nil && m.session.ID != ""
2717}
2718
2719// mimeOf detects the MIME type of the given content.
2720func mimeOf(content []byte) string {
2721 mimeBufferSize := min(512, len(content))
2722 return http.DetectContentType(content[:mimeBufferSize])
2723}
2724
2725var readyPlaceholders = [...]string{
2726 "Ready!",
2727 "Ready...",
2728 "Ready?",
2729 "Ready for instructions",
2730}
2731
2732var workingPlaceholders = [...]string{
2733 "Working!",
2734 "Working...",
2735 "Brrrrr...",
2736 "Prrrrrrrr...",
2737 "Processing...",
2738 "Thinking...",
2739}
2740
2741// randomizePlaceholders selects random placeholder text for the textarea's
2742// ready and working states.
2743func (m *UI) randomizePlaceholders() {
2744 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2745 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2746}
2747
2748// renderEditorView renders the editor view with attachments if any.
2749func (m *UI) renderEditorView(width int) string {
2750 var attachmentsView string
2751 if len(m.attachments.List()) > 0 {
2752 attachmentsView = m.attachments.Render(width)
2753 }
2754 return strings.Join([]string{
2755 attachmentsView,
2756 m.textarea.View(),
2757 "", // margin at bottom of editor
2758 }, "\n")
2759}
2760
2761// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2762func (m *UI) cacheSidebarLogo(width int) {
2763 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2764}
2765
2766// sendMessage sends a message with the given content and attachments.
2767func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2768 if !m.com.Workspace.AgentIsReady() {
2769 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2770 }
2771
2772 var cmds []tea.Cmd
2773 if !m.hasSession() {
2774 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
2775 if err != nil {
2776 return util.ReportError(err)
2777 }
2778 if m.forceCompactMode {
2779 m.isCompact = true
2780 }
2781 if newSession.ID != "" {
2782 m.session = &newSession
2783 cmds = append(cmds, m.loadSession(newSession.ID))
2784 }
2785 m.setState(uiChat, m.focus)
2786 }
2787
2788 ctx := context.Background()
2789 cmds = append(cmds, func() tea.Msg {
2790 for _, path := range m.sessionFileReads {
2791 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2792 m.com.Workspace.LSPStart(ctx, path)
2793 }
2794 return nil
2795 })
2796
2797 // Capture session ID to avoid race with main goroutine updating m.session.
2798 sessionID := m.session.ID
2799 cmds = append(cmds, func() tea.Msg {
2800 err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
2801 if err != nil {
2802 isCancelErr := errors.Is(err, context.Canceled)
2803 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2804 if isCancelErr || isPermissionErr {
2805 return nil
2806 }
2807 return util.InfoMsg{
2808 Type: util.InfoTypeError,
2809 Msg: fmt.Sprintf("Failed to run agent: %v", err),
2810 }
2811 }
2812 return nil
2813 })
2814 return tea.Batch(cmds...)
2815}
2816
2817const cancelTimerDuration = 2 * time.Second
2818
2819// cancelTimerCmd creates a command that expires the cancel timer.
2820func cancelTimerCmd() tea.Cmd {
2821 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2822 return cancelTimerExpiredMsg{}
2823 })
2824}
2825
2826// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2827// and starts a timer. The second press (before the timer expires) actually
2828// cancels the agent.
2829func (m *UI) cancelAgent() tea.Cmd {
2830 if !m.hasSession() {
2831 return nil
2832 }
2833
2834 if !m.com.Workspace.AgentIsReady() {
2835 return nil
2836 }
2837
2838 if m.isCanceling {
2839 // Second escape press - actually cancel the agent.
2840 m.isCanceling = false
2841 m.com.Workspace.AgentCancel(m.session.ID)
2842 // Stop the spinning todo indicator.
2843 m.todoIsSpinning = false
2844 m.renderPills()
2845 return nil
2846 }
2847
2848 // Check if there are queued prompts - if so, clear the queue.
2849 if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2850 m.com.Workspace.AgentClearQueue(m.session.ID)
2851 return nil
2852 }
2853
2854 // First escape press - set canceling state and start timer.
2855 m.isCanceling = true
2856 return cancelTimerCmd()
2857}
2858
2859// openDialog opens a dialog by its ID.
2860func (m *UI) openDialog(id string) tea.Cmd {
2861 var cmds []tea.Cmd
2862 switch id {
2863 case dialog.SessionsID:
2864 if cmd := m.openSessionsDialog(); cmd != nil {
2865 cmds = append(cmds, cmd)
2866 }
2867 case dialog.ModelsID:
2868 if cmd := m.openModelsDialog(); cmd != nil {
2869 cmds = append(cmds, cmd)
2870 }
2871 case dialog.CommandsID:
2872 if cmd := m.openCommandsDialog(); cmd != nil {
2873 cmds = append(cmds, cmd)
2874 }
2875 case dialog.ReasoningID:
2876 if cmd := m.openReasoningDialog(); cmd != nil {
2877 cmds = append(cmds, cmd)
2878 }
2879 case dialog.QuitID:
2880 if cmd := m.openQuitDialog(); cmd != nil {
2881 cmds = append(cmds, cmd)
2882 }
2883 default:
2884 // Unknown dialog
2885 break
2886 }
2887 return tea.Batch(cmds...)
2888}
2889
2890// openQuitDialog opens the quit confirmation dialog.
2891func (m *UI) openQuitDialog() tea.Cmd {
2892 if m.dialog.ContainsDialog(dialog.QuitID) {
2893 // Bring to front
2894 m.dialog.BringToFront(dialog.QuitID)
2895 return nil
2896 }
2897
2898 quitDialog := dialog.NewQuit(m.com)
2899 m.dialog.OpenDialog(quitDialog)
2900 return nil
2901}
2902
2903// openModelsDialog opens the models dialog.
2904func (m *UI) openModelsDialog() tea.Cmd {
2905 if m.dialog.ContainsDialog(dialog.ModelsID) {
2906 // Bring to front
2907 m.dialog.BringToFront(dialog.ModelsID)
2908 return nil
2909 }
2910
2911 isOnboarding := m.state == uiOnboarding
2912 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2913 if err != nil {
2914 return util.ReportError(err)
2915 }
2916
2917 m.dialog.OpenDialog(modelsDialog)
2918
2919 return nil
2920}
2921
2922// openCommandsDialog opens the commands dialog.
2923func (m *UI) openCommandsDialog() tea.Cmd {
2924 if m.dialog.ContainsDialog(dialog.CommandsID) {
2925 // Bring to front
2926 m.dialog.BringToFront(dialog.CommandsID)
2927 return nil
2928 }
2929
2930 var sessionID string
2931 hasSession := m.session != nil
2932 if hasSession {
2933 sessionID = m.session.ID
2934 }
2935 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
2936 hasQueue := m.promptQueue > 0
2937
2938 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
2939 if err != nil {
2940 return util.ReportError(err)
2941 }
2942
2943 m.dialog.OpenDialog(commands)
2944
2945 return nil
2946}
2947
2948// openReasoningDialog opens the reasoning effort dialog.
2949func (m *UI) openReasoningDialog() tea.Cmd {
2950 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2951 m.dialog.BringToFront(dialog.ReasoningID)
2952 return nil
2953 }
2954
2955 reasoningDialog, err := dialog.NewReasoning(m.com)
2956 if err != nil {
2957 return util.ReportError(err)
2958 }
2959
2960 m.dialog.OpenDialog(reasoningDialog)
2961 return nil
2962}
2963
2964// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2965// it brings it to the front. Otherwise, it will list all the sessions and open
2966// the dialog.
2967func (m *UI) openSessionsDialog() tea.Cmd {
2968 if m.dialog.ContainsDialog(dialog.SessionsID) {
2969 // Bring to front
2970 m.dialog.BringToFront(dialog.SessionsID)
2971 return nil
2972 }
2973
2974 selectedSessionID := ""
2975 if m.session != nil {
2976 selectedSessionID = m.session.ID
2977 }
2978
2979 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2980 if err != nil {
2981 return util.ReportError(err)
2982 }
2983
2984 m.dialog.OpenDialog(dialog)
2985 return nil
2986}
2987
2988// openFilesDialog opens the file picker dialog.
2989func (m *UI) openFilesDialog() tea.Cmd {
2990 if m.dialog.ContainsDialog(dialog.FilePickerID) {
2991 // Bring to front
2992 m.dialog.BringToFront(dialog.FilePickerID)
2993 return nil
2994 }
2995
2996 filePicker, cmd := dialog.NewFilePicker(m.com)
2997 filePicker.SetImageCapabilities(&m.caps)
2998 m.dialog.OpenDialog(filePicker)
2999
3000 return cmd
3001}
3002
3003// openPermissionsDialog opens the permissions dialog for a permission request.
3004func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3005 // Close any existing permissions dialog first.
3006 m.dialog.CloseDialog(dialog.PermissionsID)
3007
3008 // Get diff mode from config.
3009 var opts []dialog.PermissionsOption
3010 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3011 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3012 }
3013
3014 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3015 m.dialog.OpenDialog(permDialog)
3016 return nil
3017}
3018
3019// handlePermissionNotification updates tool items when permission state changes.
3020func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3021 toolItem := m.chat.MessageItem(notification.ToolCallID)
3022 if toolItem == nil {
3023 return
3024 }
3025
3026 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3027 if notification.Granted {
3028 permItem.SetStatus(chat.ToolStatusRunning)
3029 } else {
3030 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3031 }
3032 }
3033}
3034
3035// handleAgentNotification translates domain agent events into desktop
3036// notifications using the UI notification backend.
3037func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3038 switch n.Type {
3039 case notify.TypeAgentFinished:
3040 return m.sendNotification(notification.Notification{
3041 Title: "Crush is waiting...",
3042 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3043 })
3044 default:
3045 return nil
3046 }
3047}
3048
3049// newSession clears the current session state and prepares for a new session.
3050// The actual session creation happens when the user sends their first message.
3051// Returns a command to reload prompt history.
3052func (m *UI) newSession() tea.Cmd {
3053 if !m.hasSession() {
3054 return nil
3055 }
3056
3057 m.session = nil
3058 m.sessionFiles = nil
3059 m.sessionFileReads = nil
3060 m.setState(uiLanding, uiFocusEditor)
3061 m.textarea.Focus()
3062 m.chat.Blur()
3063 m.chat.ClearMessages()
3064 m.pillsExpanded = false
3065 m.promptQueue = 0
3066 m.pillsView = ""
3067 m.historyReset()
3068 agenttools.ResetCache()
3069 return tea.Batch(
3070 func() tea.Msg {
3071 m.com.Workspace.LSPStopAll(context.Background())
3072 return nil
3073 },
3074 m.loadPromptHistory(),
3075 )
3076}
3077
3078// handlePasteMsg handles a paste message.
3079func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3080 if m.dialog.HasDialogs() {
3081 return m.handleDialogMsg(msg)
3082 }
3083
3084 if m.focus != uiFocusEditor {
3085 return nil
3086 }
3087
3088 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3089 return func() tea.Msg {
3090 content := []byte(msg.Content)
3091 if int64(len(content)) > common.MaxAttachmentSize {
3092 return util.ReportWarn("Paste is too big (>5mb)")
3093 }
3094 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3095 mimeBufferSize := min(512, len(content))
3096 mimeType := http.DetectContentType(content[:mimeBufferSize])
3097 return message.Attachment{
3098 FileName: name,
3099 FilePath: name,
3100 MimeType: mimeType,
3101 Content: content,
3102 }
3103 }
3104 }
3105
3106 // Attempt to parse pasted content as file paths. If possible to parse,
3107 // all files exist and are valid, add as attachments.
3108 // Otherwise, paste as text.
3109 paths := fsext.ParsePastedFiles(msg.Content)
3110 allExistsAndValid := func() bool {
3111 if len(paths) == 0 {
3112 return false
3113 }
3114 for _, path := range paths {
3115 if _, err := os.Stat(path); os.IsNotExist(err) {
3116 return false
3117 }
3118
3119 lowerPath := strings.ToLower(path)
3120 isValid := false
3121 for _, ext := range common.AllowedImageTypes {
3122 if strings.HasSuffix(lowerPath, ext) {
3123 isValid = true
3124 break
3125 }
3126 }
3127 if !isValid {
3128 return false
3129 }
3130 }
3131 return true
3132 }
3133 if !allExistsAndValid() {
3134 var cmd tea.Cmd
3135 m.textarea, cmd = m.textarea.Update(msg)
3136 return cmd
3137 }
3138
3139 var cmds []tea.Cmd
3140 for _, path := range paths {
3141 cmds = append(cmds, m.handleFilePathPaste(path))
3142 }
3143 return tea.Batch(cmds...)
3144}
3145
3146// handleFilePathPaste handles a pasted file path.
3147func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3148 return func() tea.Msg {
3149 fileInfo, err := os.Stat(path)
3150 if err != nil {
3151 return util.ReportError(err)
3152 }
3153 if fileInfo.IsDir() {
3154 return util.ReportWarn("Cannot attach a directory")
3155 }
3156 if fileInfo.Size() > common.MaxAttachmentSize {
3157 return util.ReportWarn("File is too big (>5mb)")
3158 }
3159
3160 content, err := os.ReadFile(path)
3161 if err != nil {
3162 return util.ReportError(err)
3163 }
3164
3165 mimeBufferSize := min(512, len(content))
3166 mimeType := http.DetectContentType(content[:mimeBufferSize])
3167 fileName := filepath.Base(path)
3168 return message.Attachment{
3169 FilePath: path,
3170 FileName: fileName,
3171 MimeType: mimeType,
3172 Content: content,
3173 }
3174 }
3175}
3176
3177// pasteImageFromClipboard reads image data from the system clipboard and
3178// creates an attachment. If no image data is found, it falls back to
3179// interpreting clipboard text as a file path.
3180func (m *UI) pasteImageFromClipboard() tea.Msg {
3181 imageData, err := readClipboard(clipboardFormatImage)
3182 if int64(len(imageData)) > common.MaxAttachmentSize {
3183 return util.InfoMsg{
3184 Type: util.InfoTypeError,
3185 Msg: "File too large, max 5MB",
3186 }
3187 }
3188 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3189 if err == nil {
3190 return message.Attachment{
3191 FilePath: name,
3192 FileName: name,
3193 MimeType: mimeOf(imageData),
3194 Content: imageData,
3195 }
3196 }
3197
3198 textData, textErr := readClipboard(clipboardFormatText)
3199 if textErr != nil || len(textData) == 0 {
3200 return nil // Clipboard is empty or does not contain an image
3201 }
3202
3203 path := strings.TrimSpace(string(textData))
3204 path = strings.ReplaceAll(path, "\\ ", " ")
3205 if _, statErr := os.Stat(path); statErr != nil {
3206 return nil // Clipboard does not contain an image or valid file path
3207 }
3208
3209 lowerPath := strings.ToLower(path)
3210 isAllowed := false
3211 for _, ext := range common.AllowedImageTypes {
3212 if strings.HasSuffix(lowerPath, ext) {
3213 isAllowed = true
3214 break
3215 }
3216 }
3217 if !isAllowed {
3218 return util.NewInfoMsg("File type is not a supported image format")
3219 }
3220
3221 fileInfo, statErr := os.Stat(path)
3222 if statErr != nil {
3223 return util.InfoMsg{
3224 Type: util.InfoTypeError,
3225 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3226 }
3227 }
3228 if fileInfo.Size() > common.MaxAttachmentSize {
3229 return util.InfoMsg{
3230 Type: util.InfoTypeError,
3231 Msg: "File too large, max 5MB",
3232 }
3233 }
3234
3235 content, readErr := os.ReadFile(path)
3236 if readErr != nil {
3237 return util.InfoMsg{
3238 Type: util.InfoTypeError,
3239 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3240 }
3241 }
3242
3243 return message.Attachment{
3244 FilePath: path,
3245 FileName: filepath.Base(path),
3246 MimeType: mimeOf(content),
3247 Content: content,
3248 }
3249}
3250
3251var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3252
3253func (m *UI) pasteIdx() int {
3254 result := 0
3255 for _, at := range m.attachments.List() {
3256 found := pasteRE.FindStringSubmatch(at.FileName)
3257 if len(found) == 0 {
3258 continue
3259 }
3260 idx, err := strconv.Atoi(found[1])
3261 if err == nil {
3262 result = max(result, idx)
3263 }
3264 }
3265 return result + 1
3266}
3267
3268// drawSessionDetails draws the session details in compact mode.
3269func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3270 if m.session == nil {
3271 return
3272 }
3273
3274 s := m.com.Styles
3275
3276 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3277 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3278
3279 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3280 blocks := []string{
3281 title,
3282 "",
3283 m.modelInfo(width),
3284 "",
3285 }
3286
3287 detailsHeader := lipgloss.JoinVertical(
3288 lipgloss.Left,
3289 blocks...,
3290 )
3291
3292 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3293
3294 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3295
3296 const maxSectionWidth = 50
3297 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3298 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3299
3300 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3301 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3302 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3303 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3304 uv.NewStyledString(
3305 s.CompactDetails.View.
3306 Width(area.Dx()).
3307 Render(
3308 lipgloss.JoinVertical(
3309 lipgloss.Left,
3310 detailsHeader,
3311 sections,
3312 version,
3313 ),
3314 ),
3315 ).Draw(scr, area)
3316}
3317
3318func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3319 load := func() tea.Msg {
3320 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3321 if err != nil {
3322 // TODO: make this better
3323 return util.ReportError(err)()
3324 }
3325
3326 if prompt == "" {
3327 return nil
3328 }
3329 return sendMessageMsg{
3330 Content: prompt,
3331 }
3332 }
3333
3334 var cmds []tea.Cmd
3335 if cmd := m.dialog.StartLoading(); cmd != nil {
3336 cmds = append(cmds, cmd)
3337 }
3338 cmds = append(cmds, load, func() tea.Msg {
3339 return closeDialogMsg{}
3340 })
3341
3342 return tea.Sequence(cmds...)
3343}
3344
3345func (m *UI) handleStateChanged() tea.Cmd {
3346 return func() tea.Msg {
3347 m.com.Workspace.UpdateAgentModel(context.Background())
3348 return mcpStateChangedMsg{
3349 states: m.com.Workspace.MCPGetStates(),
3350 }
3351 }
3352}
3353
3354func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3355 return func() tea.Msg {
3356 ws.MCPRefreshPrompts(context.Background(), name)
3357 return nil
3358 }
3359}
3360
3361func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3362 return func() tea.Msg {
3363 ws.RefreshMCPTools(context.Background(), name)
3364 return nil
3365 }
3366}
3367
3368func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3369 return func() tea.Msg {
3370 ws.MCPRefreshResources(context.Background(), name)
3371 return nil
3372 }
3373}
3374
3375func (m *UI) copyChatHighlight() tea.Cmd {
3376 text := m.chat.HighlightContent()
3377 return common.CopyToClipboardWithCallback(
3378 text,
3379 "Selected text copied to clipboard",
3380 func() tea.Msg {
3381 m.chat.ClearMouse()
3382 return nil
3383 },
3384 )
3385}
3386
3387// renderLogo renders the Crush logo with the given styles and dimensions.
3388func renderLogo(t *styles.Styles, compact bool, width int) string {
3389 return logo.Render(t, version.Version, compact, logo.Opts{
3390 FieldColor: t.LogoFieldColor,
3391 TitleColorA: t.LogoTitleColorA,
3392 TitleColorB: t.LogoTitleColorB,
3393 CharmColor: t.LogoCharmColor,
3394 VersionColor: t.LogoVersionColor,
3395 Width: width,
3396 })
3397}