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