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