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