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