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