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