1package model
2
3import (
4 "bytes"
5 "cmp"
6 "context"
7 "errors"
8 "fmt"
9 "image"
10 "log/slog"
11 "math/rand"
12 "net/http"
13 "os"
14 "path/filepath"
15 "regexp"
16 "slices"
17 "strconv"
18 "strings"
19 "time"
20
21 "charm.land/bubbles/v2/help"
22 "charm.land/bubbles/v2/key"
23 "charm.land/bubbles/v2/spinner"
24 "charm.land/bubbles/v2/textarea"
25 tea "charm.land/bubbletea/v2"
26 "charm.land/catwalk/pkg/catwalk"
27 "charm.land/lipgloss/v2"
28 "github.com/charmbracelet/crush/internal/agent/notify"
29 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
30 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
31 "github.com/charmbracelet/crush/internal/commands"
32 "github.com/charmbracelet/crush/internal/config"
33 "github.com/charmbracelet/crush/internal/fsext"
34 "github.com/charmbracelet/crush/internal/history"
35 "github.com/charmbracelet/crush/internal/home"
36 "github.com/charmbracelet/crush/internal/message"
37 "github.com/charmbracelet/crush/internal/permission"
38 "github.com/charmbracelet/crush/internal/pubsub"
39 "github.com/charmbracelet/crush/internal/session"
40 "github.com/charmbracelet/crush/internal/ui/anim"
41 "github.com/charmbracelet/crush/internal/ui/attachments"
42 "github.com/charmbracelet/crush/internal/ui/chat"
43 "github.com/charmbracelet/crush/internal/ui/common"
44 "github.com/charmbracelet/crush/internal/ui/completions"
45 "github.com/charmbracelet/crush/internal/ui/dialog"
46 fimage "github.com/charmbracelet/crush/internal/ui/image"
47 "github.com/charmbracelet/crush/internal/ui/logo"
48 "github.com/charmbracelet/crush/internal/ui/notification"
49 "github.com/charmbracelet/crush/internal/ui/styles"
50 "github.com/charmbracelet/crush/internal/ui/util"
51 "github.com/charmbracelet/crush/internal/version"
52 "github.com/charmbracelet/crush/internal/workspace"
53 uv "github.com/charmbracelet/ultraviolet"
54 "github.com/charmbracelet/ultraviolet/layout"
55 "github.com/charmbracelet/ultraviolet/screen"
56 "github.com/charmbracelet/x/editor"
57)
58
59// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
60// wheel event occurs.
61const MouseScrollThreshold = 5
62
63// Compact mode breakpoints.
64const (
65 compactModeWidthBreakpoint = 120
66 compactModeHeightBreakpoint = 30
67)
68
69// If pasted text has more than 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]workspace.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]workspace.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, _ := com.Workspace.ProjectNeedsInitialization(); 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.Workspace.AgentQueuedPrompts(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.Workspace.ListMessages(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[workspace.LSPEvent]:
571 m.lspStates = m.com.Workspace.LSPGetStates()
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(m.com.Workspace, msg.Payload.Name)
581 case mcp.EventToolsListChanged:
582 return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
583 case mcp.EventResourcesListChanged:
584 return m, handleMCPResourcesEvent(m.com.Workspace, 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 if msg.Type == util.InfoTypeError {
795 slog.Error("Error reported", "error", msg.Msg)
796 }
797 m.status.SetInfoMsg(msg)
798 ttl := msg.TTL
799 if ttl <= 0 {
800 ttl = DefaultStatusTTL
801 }
802 cmds = append(cmds, clearInfoMsgCmd(ttl))
803 case util.ClearStatusMsg:
804 m.status.ClearInfoMsg()
805 case completions.CompletionItemsLoadedMsg:
806 if m.completionsOpen {
807 m.completions.SetItems(msg.Files, msg.Resources)
808 }
809 case uv.KittyGraphicsEvent:
810 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
811 slog.Warn("Unexpected Kitty graphics response",
812 "response", string(msg.Payload),
813 "options", msg.Options)
814 }
815 default:
816 if m.dialog.HasDialogs() {
817 if cmd := m.handleDialogMsg(msg); cmd != nil {
818 cmds = append(cmds, cmd)
819 }
820 }
821 }
822
823 // This logic gets triggered on any message type, but should it?
824 switch m.focus {
825 case uiFocusMain:
826 case uiFocusEditor:
827 // Textarea placeholder logic
828 if m.isAgentBusy() {
829 m.textarea.Placeholder = m.workingPlaceholder
830 } else {
831 m.textarea.Placeholder = m.readyPlaceholder
832 }
833 if m.com.Workspace.PermissionSkipRequests() {
834 m.textarea.Placeholder = "Yolo mode!"
835 }
836 }
837
838 // at this point this can only handle [message.Attachment] message, and we
839 // should return all cmds anyway.
840 _ = m.attachments.Update(msg)
841 return m, tea.Batch(cmds...)
842}
843
844// setSessionMessages sets the messages for the current session in the chat
845func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
846 var cmds []tea.Cmd
847 // Build tool result map to link tool calls with their results
848 msgPtrs := make([]*message.Message, len(msgs))
849 for i := range msgs {
850 msgPtrs[i] = &msgs[i]
851 }
852 toolResultMap := chat.BuildToolResultMap(msgPtrs)
853 if len(msgPtrs) > 0 {
854 m.lastUserMessageTime = msgPtrs[0].CreatedAt
855 }
856
857 // Add messages to chat with linked tool results
858 items := make([]chat.MessageItem, 0, len(msgs)*2)
859 for _, msg := range msgPtrs {
860 switch msg.Role {
861 case message.User:
862 m.lastUserMessageTime = msg.CreatedAt
863 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
864 case message.Assistant:
865 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
866 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
867 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
868 items = append(items, infoItem)
869 }
870 default:
871 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
872 }
873 }
874
875 // Load nested tool calls for agent/agentic_fetch tools.
876 m.loadNestedToolCalls(items)
877
878 // If the user switches between sessions while the agent is working we want
879 // to make sure the animations are shown.
880 for _, item := range items {
881 if animatable, ok := item.(chat.Animatable); ok {
882 if cmd := animatable.StartAnimation(); cmd != nil {
883 cmds = append(cmds, cmd)
884 }
885 }
886 }
887
888 m.chat.SetMessages(items...)
889 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
890 cmds = append(cmds, cmd)
891 }
892 m.chat.SelectLast()
893 return tea.Sequence(cmds...)
894}
895
896// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
897func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
898 for _, item := range items {
899 nestedContainer, ok := item.(chat.NestedToolContainer)
900 if !ok {
901 continue
902 }
903 toolItem, ok := item.(chat.ToolMessageItem)
904 if !ok {
905 continue
906 }
907
908 tc := toolItem.ToolCall()
909 messageID := toolItem.MessageID()
910
911 // Get the agent tool session ID.
912 agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
913
914 // Fetch nested messages.
915 nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
916 if err != nil || len(nestedMsgs) == 0 {
917 continue
918 }
919
920 // Build tool result map for nested messages.
921 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
922 for i := range nestedMsgs {
923 nestedMsgPtrs[i] = &nestedMsgs[i]
924 }
925 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
926
927 // Extract nested tool items.
928 var nestedTools []chat.ToolMessageItem
929 for _, nestedMsg := range nestedMsgPtrs {
930 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
931 for _, nestedItem := range nestedItems {
932 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
933 // Mark nested tools as simple (compact) rendering.
934 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
935 simplifiable.SetCompact(true)
936 }
937 nestedTools = append(nestedTools, nestedToolItem)
938 }
939 }
940 }
941
942 // Recursively load nested tool calls for any agent tools within.
943 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
944 for i, nt := range nestedTools {
945 nestedMessageItems[i] = nt
946 }
947 m.loadNestedToolCalls(nestedMessageItems)
948
949 // Set nested tools on the parent.
950 nestedContainer.SetNestedTools(nestedTools)
951 }
952}
953
954// appendSessionMessage appends a new message to the current session in the chat
955// if the message is a tool result it will update the corresponding tool call message
956func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
957 var cmds []tea.Cmd
958
959 existing := m.chat.MessageItem(msg.ID)
960 if existing != nil {
961 // message already exists, skip
962 return nil
963 }
964
965 switch msg.Role {
966 case message.User:
967 m.lastUserMessageTime = msg.CreatedAt
968 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
969 for _, item := range items {
970 if animatable, ok := item.(chat.Animatable); ok {
971 if cmd := animatable.StartAnimation(); cmd != nil {
972 cmds = append(cmds, cmd)
973 }
974 }
975 }
976 m.chat.AppendMessages(items...)
977 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
978 cmds = append(cmds, cmd)
979 }
980 case message.Assistant:
981 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
982 for _, item := range items {
983 if animatable, ok := item.(chat.Animatable); ok {
984 if cmd := animatable.StartAnimation(); cmd != nil {
985 cmds = append(cmds, cmd)
986 }
987 }
988 }
989 m.chat.AppendMessages(items...)
990 if m.chat.Follow() {
991 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
992 cmds = append(cmds, cmd)
993 }
994 }
995 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
996 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
997 m.chat.AppendMessages(infoItem)
998 if m.chat.Follow() {
999 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1000 cmds = append(cmds, cmd)
1001 }
1002 }
1003 }
1004 case message.Tool:
1005 for _, tr := range msg.ToolResults() {
1006 toolItem := m.chat.MessageItem(tr.ToolCallID)
1007 if toolItem == nil {
1008 // we should have an item!
1009 continue
1010 }
1011 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1012 toolMsgItem.SetResult(&tr)
1013 if m.chat.Follow() {
1014 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1015 cmds = append(cmds, cmd)
1016 }
1017 }
1018 }
1019 }
1020 }
1021 return tea.Sequence(cmds...)
1022}
1023
1024func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1025 switch {
1026 case m.state != uiChat:
1027 return nil
1028 case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1029 return nil
1030 case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1031 m.focus = uiFocusEditor
1032 cmd = m.textarea.Focus()
1033 m.chat.Blur()
1034 case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1035 m.focus = uiFocusMain
1036 m.textarea.Blur()
1037 m.chat.Focus()
1038 }
1039 return cmd
1040}
1041
1042// updateSessionMessage updates an existing message in the current session in the chat
1043// when an assistant message is updated it may include updated tool calls as well
1044// that is why we need to handle creating/updating each tool call message too
1045func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1046 var cmds []tea.Cmd
1047 existingItem := m.chat.MessageItem(msg.ID)
1048
1049 if existingItem != nil {
1050 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1051 assistantItem.SetMessage(&msg)
1052 }
1053 }
1054
1055 shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1056 // if the message of the assistant does not have any response just tool calls we need to remove it
1057 if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1058 m.chat.RemoveMessage(msg.ID)
1059 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1060 m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1061 }
1062 }
1063
1064 if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1065 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1066 newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1067 m.chat.AppendMessages(newInfoItem)
1068 }
1069 }
1070
1071 var items []chat.MessageItem
1072 for _, tc := range msg.ToolCalls() {
1073 existingToolItem := m.chat.MessageItem(tc.ID)
1074 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1075 existingToolCall := toolItem.ToolCall()
1076 // only update if finished state changed or input changed
1077 // to avoid clearing the cache
1078 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1079 toolItem.SetToolCall(tc)
1080 }
1081 }
1082 if existingToolItem == nil {
1083 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1084 }
1085 }
1086
1087 for _, item := range items {
1088 if animatable, ok := item.(chat.Animatable); ok {
1089 if cmd := animatable.StartAnimation(); cmd != nil {
1090 cmds = append(cmds, cmd)
1091 }
1092 }
1093 }
1094
1095 m.chat.AppendMessages(items...)
1096 if m.chat.Follow() {
1097 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1098 cmds = append(cmds, cmd)
1099 }
1100 m.chat.SelectLast()
1101 }
1102
1103 return tea.Sequence(cmds...)
1104}
1105
1106// handleChildSessionMessage handles messages from child sessions (agent tools).
1107func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1108 var cmds []tea.Cmd
1109
1110 // Only process messages with tool calls or results.
1111 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1112 return nil
1113 }
1114
1115 // Check if this is an agent tool session and parse it.
1116 childSessionID := event.Payload.SessionID
1117 _, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
1118 if !ok {
1119 return nil
1120 }
1121
1122 // Find the parent agent tool item.
1123 var agentItem chat.NestedToolContainer
1124 for i := 0; i < m.chat.Len(); i++ {
1125 item := m.chat.MessageItem(toolCallID)
1126 if item == nil {
1127 continue
1128 }
1129 if agent, ok := item.(chat.NestedToolContainer); ok {
1130 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1131 if toolMessageItem.ToolCall().ID == toolCallID {
1132 // Verify this agent belongs to the correct parent message.
1133 // We can't directly check parentMessageID on the item, so we trust the session parsing.
1134 agentItem = agent
1135 break
1136 }
1137 }
1138 }
1139 }
1140
1141 if agentItem == nil {
1142 return nil
1143 }
1144
1145 // Get existing nested tools.
1146 nestedTools := agentItem.NestedTools()
1147
1148 // Update or create nested tool calls.
1149 for _, tc := range event.Payload.ToolCalls() {
1150 found := false
1151 for _, existingTool := range nestedTools {
1152 if existingTool.ToolCall().ID == tc.ID {
1153 existingTool.SetToolCall(tc)
1154 found = true
1155 break
1156 }
1157 }
1158 if !found {
1159 // Create a new nested tool item.
1160 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1161 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1162 simplifiable.SetCompact(true)
1163 }
1164 if animatable, ok := nestedItem.(chat.Animatable); ok {
1165 if cmd := animatable.StartAnimation(); cmd != nil {
1166 cmds = append(cmds, cmd)
1167 }
1168 }
1169 nestedTools = append(nestedTools, nestedItem)
1170 }
1171 }
1172
1173 // Update nested tool results.
1174 for _, tr := range event.Payload.ToolResults() {
1175 for _, nestedTool := range nestedTools {
1176 if nestedTool.ToolCall().ID == tr.ToolCallID {
1177 nestedTool.SetResult(&tr)
1178 break
1179 }
1180 }
1181 }
1182
1183 // Update the agent item with the new nested tools.
1184 agentItem.SetNestedTools(nestedTools)
1185
1186 // Update the chat so it updates the index map for animations to work as expected
1187 m.chat.UpdateNestedToolIDs(toolCallID)
1188
1189 if m.chat.Follow() {
1190 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1191 cmds = append(cmds, cmd)
1192 }
1193 m.chat.SelectLast()
1194 }
1195
1196 return tea.Sequence(cmds...)
1197}
1198
1199func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1200 var cmds []tea.Cmd
1201 action := m.dialog.Update(msg)
1202 if action == nil {
1203 return tea.Batch(cmds...)
1204 }
1205
1206 isOnboarding := m.state == uiOnboarding
1207
1208 switch msg := action.(type) {
1209 // Generic dialog messages
1210 case dialog.ActionClose:
1211 if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1212 break
1213 }
1214
1215 if m.dialog.ContainsDialog(dialog.FilePickerID) {
1216 defer fimage.ResetCache()
1217 }
1218
1219 m.dialog.CloseFrontDialog()
1220
1221 if isOnboarding {
1222 if cmd := m.openModelsDialog(); cmd != nil {
1223 cmds = append(cmds, cmd)
1224 }
1225 }
1226
1227 if m.focus == uiFocusEditor {
1228 cmds = append(cmds, m.textarea.Focus())
1229 }
1230 case dialog.ActionCmd:
1231 if msg.Cmd != nil {
1232 cmds = append(cmds, msg.Cmd)
1233 }
1234
1235 // Session dialog messages.
1236 case dialog.ActionSelectSession:
1237 m.dialog.CloseDialog(dialog.SessionsID)
1238 cmds = append(cmds, m.loadSession(msg.Session.ID))
1239
1240 // Open dialog message.
1241 case dialog.ActionOpenDialog:
1242 m.dialog.CloseDialog(dialog.CommandsID)
1243 if cmd := m.openDialog(msg.DialogID); cmd != nil {
1244 cmds = append(cmds, cmd)
1245 }
1246
1247 // Command dialog messages.
1248 case dialog.ActionToggleYoloMode:
1249 yolo := !m.com.Workspace.PermissionSkipRequests()
1250 m.com.Workspace.PermissionSetSkipRequests(yolo)
1251 m.setEditorPrompt(yolo)
1252 m.dialog.CloseDialog(dialog.CommandsID)
1253 case dialog.ActionToggleNotifications:
1254 cfg := m.com.Config()
1255 if cfg != nil && cfg.Options != nil {
1256 disabled := !cfg.Options.DisableNotifications
1257 cfg.Options.DisableNotifications = disabled
1258 if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1259 cmds = append(cmds, util.ReportError(err))
1260 } else {
1261 status := "enabled"
1262 if disabled {
1263 status = "disabled"
1264 }
1265 cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
1266 }
1267 }
1268 m.dialog.CloseDialog(dialog.CommandsID)
1269 case dialog.ActionNewSession:
1270 if m.isAgentBusy() {
1271 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1272 break
1273 }
1274 if cmd := m.newSession(); cmd != nil {
1275 cmds = append(cmds, cmd)
1276 }
1277 m.dialog.CloseDialog(dialog.CommandsID)
1278 case dialog.ActionSummarize:
1279 if m.isAgentBusy() {
1280 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1281 break
1282 }
1283 cmds = append(cmds, func() tea.Msg {
1284 err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1285 if err != nil {
1286 return util.ReportError(err)()
1287 }
1288 return nil
1289 })
1290 m.dialog.CloseDialog(dialog.CommandsID)
1291 case dialog.ActionToggleHelp:
1292 m.status.ToggleHelp()
1293 m.dialog.CloseDialog(dialog.CommandsID)
1294 case dialog.ActionExternalEditor:
1295 if m.isAgentBusy() {
1296 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1297 break
1298 }
1299 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1300 m.dialog.CloseDialog(dialog.CommandsID)
1301 case dialog.ActionToggleCompactMode:
1302 cmds = append(cmds, m.toggleCompactMode())
1303 m.dialog.CloseDialog(dialog.CommandsID)
1304 case dialog.ActionTogglePills:
1305 if cmd := m.togglePillsExpanded(); cmd != nil {
1306 cmds = append(cmds, cmd)
1307 }
1308 m.dialog.CloseDialog(dialog.CommandsID)
1309 case dialog.ActionToggleThinking:
1310 cmds = append(cmds, func() tea.Msg {
1311 cfg := m.com.Config()
1312 if cfg == nil {
1313 return util.ReportError(errors.New("configuration not found"))()
1314 }
1315
1316 agentCfg, ok := cfg.Agents[config.AgentCoder]
1317 if !ok {
1318 return util.ReportError(errors.New("agent configuration not found"))()
1319 }
1320
1321 currentModel := cfg.Models[agentCfg.Model]
1322 currentModel.Think = !currentModel.Think
1323 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1324 return util.ReportError(err)()
1325 }
1326 m.com.Workspace.UpdateAgentModel(context.TODO())
1327 status := "disabled"
1328 if currentModel.Think {
1329 status = "enabled"
1330 }
1331 return util.NewInfoMsg("Thinking mode " + status)
1332 })
1333 m.dialog.CloseDialog(dialog.CommandsID)
1334 case dialog.ActionToggleTransparentBackground:
1335 cmds = append(cmds, func() tea.Msg {
1336 cfg := m.com.Config()
1337 if cfg == nil {
1338 return util.ReportError(errors.New("configuration not found"))()
1339 }
1340
1341 isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
1342 newValue := !isTransparent
1343 if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.tui.transparent", newValue); err != nil {
1344 return util.ReportError(err)()
1345 }
1346 m.isTransparent = newValue
1347
1348 status := "disabled"
1349 if newValue {
1350 status = "enabled"
1351 }
1352 return util.NewInfoMsg("Transparent background " + status)
1353 })
1354 m.dialog.CloseDialog(dialog.CommandsID)
1355 case dialog.ActionQuit:
1356 cmds = append(cmds, tea.Quit)
1357 case dialog.ActionInitializeProject:
1358 if m.isAgentBusy() {
1359 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1360 break
1361 }
1362 cmds = append(cmds, m.initializeProject())
1363 m.dialog.CloseDialog(dialog.CommandsID)
1364
1365 case dialog.ActionSelectModel:
1366 if m.isAgentBusy() {
1367 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1368 break
1369 }
1370
1371 cfg := m.com.Config()
1372 if cfg == nil {
1373 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1374 break
1375 }
1376
1377 var (
1378 providerID = msg.Model.Provider
1379 isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1380 isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1381 )
1382
1383 // Attempt to import GitHub Copilot tokens from VSCode if available.
1384 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1385 m.com.Workspace.ImportCopilot()
1386 }
1387
1388 if !isConfigured() || msg.ReAuthenticate {
1389 m.dialog.CloseDialog(dialog.ModelsID)
1390 if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1391 cmds = append(cmds, cmd)
1392 }
1393 break
1394 }
1395
1396 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1397 cmds = append(cmds, util.ReportError(err))
1398 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1399 // Ensure small model is set is unset.
1400 smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1401 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1402 cmds = append(cmds, util.ReportError(err))
1403 }
1404 }
1405
1406 cmds = append(cmds, func() tea.Msg {
1407 if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1408 return util.ReportError(err)
1409 }
1410
1411 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1412
1413 return util.NewInfoMsg(modelMsg)
1414 })
1415
1416 m.dialog.CloseDialog(dialog.APIKeyInputID)
1417 m.dialog.CloseDialog(dialog.OAuthID)
1418 m.dialog.CloseDialog(dialog.ModelsID)
1419
1420 if isOnboarding {
1421 m.setState(uiLanding, uiFocusEditor)
1422 m.com.Config().SetupAgents()
1423 if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1424 cmds = append(cmds, util.ReportError(err))
1425 }
1426 }
1427 case dialog.ActionSelectReasoningEffort:
1428 if m.isAgentBusy() {
1429 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1430 break
1431 }
1432
1433 cfg := m.com.Config()
1434 if cfg == nil {
1435 cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1436 break
1437 }
1438
1439 agentCfg, ok := cfg.Agents[config.AgentCoder]
1440 if !ok {
1441 cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1442 break
1443 }
1444
1445 currentModel := cfg.Models[agentCfg.Model]
1446 currentModel.ReasoningEffort = msg.Effort
1447 if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1448 cmds = append(cmds, util.ReportError(err))
1449 break
1450 }
1451
1452 cmds = append(cmds, func() tea.Msg {
1453 m.com.Workspace.UpdateAgentModel(context.TODO())
1454 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1455 })
1456 m.dialog.CloseDialog(dialog.ReasoningID)
1457 case dialog.ActionPermissionResponse:
1458 m.dialog.CloseDialog(dialog.PermissionsID)
1459 switch msg.Action {
1460 case dialog.PermissionAllow:
1461 m.com.Workspace.PermissionGrant(msg.Permission)
1462 case dialog.PermissionAllowForSession:
1463 m.com.Workspace.PermissionGrantPersistent(msg.Permission)
1464 case dialog.PermissionDeny:
1465 m.com.Workspace.PermissionDeny(msg.Permission)
1466 }
1467
1468 case dialog.ActionFilePickerSelected:
1469 cmds = append(cmds, tea.Sequence(
1470 msg.Cmd(),
1471 func() tea.Msg {
1472 m.dialog.CloseDialog(dialog.FilePickerID)
1473 return nil
1474 },
1475 func() tea.Msg {
1476 fimage.ResetCache()
1477 return nil
1478 },
1479 ))
1480
1481 case dialog.ActionRunCustomCommand:
1482 if len(msg.Arguments) > 0 && msg.Args == nil {
1483 m.dialog.CloseFrontDialog()
1484 argsDialog := dialog.NewArguments(
1485 m.com,
1486 "Custom Command Arguments",
1487 "",
1488 msg.Arguments,
1489 msg, // Pass the action as the result
1490 )
1491 m.dialog.OpenDialog(argsDialog)
1492 break
1493 }
1494 content := msg.Content
1495 if msg.Args != nil {
1496 content = substituteArgs(content, msg.Args)
1497 }
1498 cmds = append(cmds, m.sendMessage(content))
1499 m.dialog.CloseFrontDialog()
1500 case dialog.ActionRunMCPPrompt:
1501 if len(msg.Arguments) > 0 && msg.Args == nil {
1502 m.dialog.CloseFrontDialog()
1503 title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1504 argsDialog := dialog.NewArguments(
1505 m.com,
1506 title,
1507 msg.Description,
1508 msg.Arguments,
1509 msg, // Pass the action as the result
1510 )
1511 m.dialog.OpenDialog(argsDialog)
1512 break
1513 }
1514 cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1515 default:
1516 cmds = append(cmds, util.CmdHandler(msg))
1517 }
1518
1519 return tea.Batch(cmds...)
1520}
1521
1522// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1523func substituteArgs(content string, args map[string]string) string {
1524 for name, value := range args {
1525 placeholder := "$" + name
1526 content = strings.ReplaceAll(content, placeholder, value)
1527 }
1528 return content
1529}
1530
1531func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1532 var (
1533 dlg dialog.Dialog
1534 cmd tea.Cmd
1535
1536 isOnboarding = m.state == uiOnboarding
1537 )
1538
1539 switch provider.ID {
1540 case "hyper":
1541 dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1542 case catwalk.InferenceProviderCopilot:
1543 dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1544 default:
1545 dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1546 }
1547
1548 if m.dialog.ContainsDialog(dlg.ID()) {
1549 m.dialog.BringToFront(dlg.ID())
1550 return nil
1551 }
1552
1553 m.dialog.OpenDialog(dlg)
1554 return cmd
1555}
1556
1557func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1558 var cmds []tea.Cmd
1559
1560 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1561 switch {
1562 case key.Matches(msg, m.keyMap.Help):
1563 m.status.ToggleHelp()
1564 m.updateLayoutAndSize()
1565 return true
1566 case key.Matches(msg, m.keyMap.Commands):
1567 if cmd := m.openCommandsDialog(); cmd != nil {
1568 cmds = append(cmds, cmd)
1569 }
1570 return true
1571 case key.Matches(msg, m.keyMap.Models):
1572 if cmd := m.openModelsDialog(); cmd != nil {
1573 cmds = append(cmds, cmd)
1574 }
1575 return true
1576 case key.Matches(msg, m.keyMap.Sessions):
1577 if cmd := m.openSessionsDialog(); cmd != nil {
1578 cmds = append(cmds, cmd)
1579 }
1580 return true
1581 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1582 m.detailsOpen = !m.detailsOpen
1583 m.updateLayoutAndSize()
1584 return true
1585 case key.Matches(msg, m.keyMap.Chat.TogglePills):
1586 if m.state == uiChat && m.hasSession() {
1587 if cmd := m.togglePillsExpanded(); cmd != nil {
1588 cmds = append(cmds, cmd)
1589 }
1590 return true
1591 }
1592 case key.Matches(msg, m.keyMap.Chat.PillLeft):
1593 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1594 if cmd := m.switchPillSection(-1); cmd != nil {
1595 cmds = append(cmds, cmd)
1596 }
1597 return true
1598 }
1599 case key.Matches(msg, m.keyMap.Chat.PillRight):
1600 if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1601 if cmd := m.switchPillSection(1); cmd != nil {
1602 cmds = append(cmds, cmd)
1603 }
1604 return true
1605 }
1606 case key.Matches(msg, m.keyMap.Suspend):
1607 if m.isAgentBusy() {
1608 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1609 return true
1610 }
1611 cmds = append(cmds, tea.Suspend)
1612 return true
1613 }
1614 return false
1615 }
1616
1617 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1618 // Always handle quit keys first
1619 if cmd := m.openQuitDialog(); cmd != nil {
1620 cmds = append(cmds, cmd)
1621 }
1622
1623 return tea.Batch(cmds...)
1624 }
1625
1626 // Route all messages to dialog if one is open.
1627 if m.dialog.HasDialogs() {
1628 return m.handleDialogMsg(msg)
1629 }
1630
1631 // Handle cancel key when agent is busy.
1632 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1633 if m.isAgentBusy() {
1634 if cmd := m.cancelAgent(); cmd != nil {
1635 cmds = append(cmds, cmd)
1636 }
1637 return tea.Batch(cmds...)
1638 }
1639 }
1640
1641 switch m.state {
1642 case uiOnboarding:
1643 return tea.Batch(cmds...)
1644 case uiInitialize:
1645 cmds = append(cmds, m.updateInitializeView(msg)...)
1646 return tea.Batch(cmds...)
1647 case uiChat, uiLanding:
1648 switch m.focus {
1649 case uiFocusEditor:
1650 // Handle completions if open.
1651 if m.completionsOpen {
1652 if msg, ok := m.completions.Update(msg); ok {
1653 switch msg := msg.(type) {
1654 case completions.SelectionMsg[completions.FileCompletionValue]:
1655 cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1656 if !msg.KeepOpen {
1657 m.closeCompletions()
1658 }
1659 case completions.SelectionMsg[completions.ResourceCompletionValue]:
1660 cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1661 if !msg.KeepOpen {
1662 m.closeCompletions()
1663 }
1664 case completions.ClosedMsg:
1665 m.completionsOpen = false
1666 }
1667 return tea.Batch(cmds...)
1668 }
1669 }
1670
1671 if ok := m.attachments.Update(msg); ok {
1672 return tea.Batch(cmds...)
1673 }
1674
1675 switch {
1676 case key.Matches(msg, m.keyMap.Editor.AddImage):
1677 if cmd := m.openFilesDialog(); cmd != nil {
1678 cmds = append(cmds, cmd)
1679 }
1680
1681 case key.Matches(msg, m.keyMap.Editor.PasteImage):
1682 cmds = append(cmds, m.pasteImageFromClipboard)
1683
1684 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1685 value := m.textarea.Value()
1686 if before, ok := strings.CutSuffix(value, "\\"); ok {
1687 // If the last character is a backslash, remove it and add a newline.
1688 m.textarea.SetValue(before)
1689 break
1690 }
1691
1692 // Otherwise, send the message
1693 m.textarea.Reset()
1694
1695 value = strings.TrimSpace(value)
1696 if value == "exit" || value == "quit" {
1697 return m.openQuitDialog()
1698 }
1699
1700 attachments := m.attachments.List()
1701 m.attachments.Reset()
1702 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1703 return nil
1704 }
1705
1706 m.randomizePlaceholders()
1707 m.historyReset()
1708
1709 return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1710 case key.Matches(msg, m.keyMap.Chat.NewSession):
1711 if !m.hasSession() {
1712 break
1713 }
1714 if m.isAgentBusy() {
1715 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1716 break
1717 }
1718 if cmd := m.newSession(); cmd != nil {
1719 cmds = append(cmds, cmd)
1720 }
1721 case key.Matches(msg, m.keyMap.Tab):
1722 if m.state != uiLanding {
1723 m.setState(m.state, uiFocusMain)
1724 m.textarea.Blur()
1725 m.chat.Focus()
1726 m.chat.SetSelected(m.chat.Len() - 1)
1727 }
1728 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1729 if m.isAgentBusy() {
1730 cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1731 break
1732 }
1733 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1734 case key.Matches(msg, m.keyMap.Editor.Newline):
1735 m.textarea.InsertRune('\n')
1736 m.closeCompletions()
1737 ta, cmd := m.textarea.Update(msg)
1738 m.textarea = ta
1739 cmds = append(cmds, cmd)
1740 case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1741 cmd := m.handleHistoryUp(msg)
1742 if cmd != nil {
1743 cmds = append(cmds, cmd)
1744 }
1745 case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1746 cmd := m.handleHistoryDown(msg)
1747 if cmd != nil {
1748 cmds = append(cmds, cmd)
1749 }
1750 case key.Matches(msg, m.keyMap.Editor.Escape):
1751 cmd := m.handleHistoryEscape(msg)
1752 if cmd != nil {
1753 cmds = append(cmds, cmd)
1754 }
1755 case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1756 if cmd := m.openCommandsDialog(); cmd != nil {
1757 cmds = append(cmds, cmd)
1758 }
1759 default:
1760 if handleGlobalKeys(msg) {
1761 // Handle global keys first before passing to textarea.
1762 break
1763 }
1764
1765 // Check for @ trigger before passing to textarea.
1766 curValue := m.textarea.Value()
1767 curIdx := len(curValue)
1768
1769 // Trigger completions on @.
1770 if msg.String() == "@" && !m.completionsOpen {
1771 // Only show if beginning of prompt or after whitespace.
1772 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1773 m.completionsOpen = true
1774 m.completionsQuery = ""
1775 m.completionsStartIndex = curIdx
1776 m.completionsPositionStart = m.completionsPosition()
1777 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1778 cmds = append(cmds, m.completions.Open(depth, limit))
1779 }
1780 }
1781
1782 // remove the details if they are open when user starts typing
1783 if m.detailsOpen {
1784 m.detailsOpen = false
1785 m.updateLayoutAndSize()
1786 }
1787
1788 ta, cmd := m.textarea.Update(msg)
1789 m.textarea = ta
1790 cmds = append(cmds, cmd)
1791
1792 // Any text modification becomes the current draft.
1793 m.updateHistoryDraft(curValue)
1794
1795 // After updating textarea, check if we need to filter completions.
1796 // Skip filtering on the initial @ keystroke since items are loading async.
1797 if m.completionsOpen && msg.String() != "@" {
1798 newValue := m.textarea.Value()
1799 newIdx := len(newValue)
1800
1801 // Close completions if cursor moved before start.
1802 if newIdx <= m.completionsStartIndex {
1803 m.closeCompletions()
1804 } else if msg.String() == "space" {
1805 // Close on space.
1806 m.closeCompletions()
1807 } else {
1808 // Extract current word and filter.
1809 word := m.textareaWord()
1810 if strings.HasPrefix(word, "@") {
1811 m.completionsQuery = word[1:]
1812 m.completions.Filter(m.completionsQuery)
1813 } else if m.completionsOpen {
1814 m.closeCompletions()
1815 }
1816 }
1817 }
1818 }
1819 case uiFocusMain:
1820 switch {
1821 case key.Matches(msg, m.keyMap.Tab):
1822 m.focus = uiFocusEditor
1823 cmds = append(cmds, m.textarea.Focus())
1824 m.chat.Blur()
1825 case key.Matches(msg, m.keyMap.Chat.NewSession):
1826 if !m.hasSession() {
1827 break
1828 }
1829 if m.isAgentBusy() {
1830 cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1831 break
1832 }
1833 m.focus = uiFocusEditor
1834 if cmd := m.newSession(); cmd != nil {
1835 cmds = append(cmds, cmd)
1836 }
1837 case key.Matches(msg, m.keyMap.Chat.Expand):
1838 m.chat.ToggleExpandedSelectedItem()
1839 case key.Matches(msg, m.keyMap.Chat.Up):
1840 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1841 cmds = append(cmds, cmd)
1842 }
1843 if !m.chat.SelectedItemInView() {
1844 m.chat.SelectPrev()
1845 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1846 cmds = append(cmds, cmd)
1847 }
1848 }
1849 case key.Matches(msg, m.keyMap.Chat.Down):
1850 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1851 cmds = append(cmds, cmd)
1852 }
1853 if !m.chat.SelectedItemInView() {
1854 m.chat.SelectNext()
1855 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1856 cmds = append(cmds, cmd)
1857 }
1858 }
1859 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1860 m.chat.SelectPrev()
1861 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1862 cmds = append(cmds, cmd)
1863 }
1864 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1865 m.chat.SelectNext()
1866 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1867 cmds = append(cmds, cmd)
1868 }
1869 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1870 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1871 cmds = append(cmds, cmd)
1872 }
1873 m.chat.SelectFirstInView()
1874 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1875 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1876 cmds = append(cmds, cmd)
1877 }
1878 m.chat.SelectLastInView()
1879 case key.Matches(msg, m.keyMap.Chat.PageUp):
1880 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1881 cmds = append(cmds, cmd)
1882 }
1883 m.chat.SelectFirstInView()
1884 case key.Matches(msg, m.keyMap.Chat.PageDown):
1885 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1886 cmds = append(cmds, cmd)
1887 }
1888 m.chat.SelectLastInView()
1889 case key.Matches(msg, m.keyMap.Chat.Home):
1890 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1891 cmds = append(cmds, cmd)
1892 }
1893 m.chat.SelectFirst()
1894 case key.Matches(msg, m.keyMap.Chat.End):
1895 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1896 cmds = append(cmds, cmd)
1897 }
1898 m.chat.SelectLast()
1899 default:
1900 if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1901 cmds = append(cmds, cmd)
1902 } else {
1903 handleGlobalKeys(msg)
1904 }
1905 }
1906 default:
1907 handleGlobalKeys(msg)
1908 }
1909 default:
1910 handleGlobalKeys(msg)
1911 }
1912
1913 return tea.Sequence(cmds...)
1914}
1915
1916// drawHeader draws the header section of the UI.
1917func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1918 m.header.drawHeader(
1919 scr,
1920 area,
1921 m.session,
1922 m.isCompact,
1923 m.detailsOpen,
1924 area.Dx(),
1925 )
1926}
1927
1928// Draw implements [uv.Drawable] and draws the UI model.
1929func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1930 layout := m.generateLayout(area.Dx(), area.Dy())
1931
1932 if m.layout != layout {
1933 m.layout = layout
1934 m.updateSize()
1935 }
1936
1937 // Clear the screen first
1938 screen.Clear(scr)
1939
1940 switch m.state {
1941 case uiOnboarding:
1942 m.drawHeader(scr, layout.header)
1943
1944 // NOTE: Onboarding flow will be rendered as dialogs below, but
1945 // positioned at the bottom left of the screen.
1946
1947 case uiInitialize:
1948 m.drawHeader(scr, layout.header)
1949
1950 main := uv.NewStyledString(m.initializeView())
1951 main.Draw(scr, layout.main)
1952
1953 case uiLanding:
1954 m.drawHeader(scr, layout.header)
1955 main := uv.NewStyledString(m.landingView())
1956 main.Draw(scr, layout.main)
1957
1958 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1959 editor.Draw(scr, layout.editor)
1960
1961 case uiChat:
1962 if m.isCompact {
1963 m.drawHeader(scr, layout.header)
1964 } else {
1965 m.drawSidebar(scr, layout.sidebar)
1966 }
1967
1968 m.chat.Draw(scr, layout.main)
1969 if layout.pills.Dy() > 0 && m.pillsView != "" {
1970 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1971 }
1972
1973 editorWidth := scr.Bounds().Dx()
1974 if !m.isCompact {
1975 editorWidth -= layout.sidebar.Dx()
1976 }
1977 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1978 editor.Draw(scr, layout.editor)
1979
1980 // Draw details overlay in compact mode when open
1981 if m.isCompact && m.detailsOpen {
1982 m.drawSessionDetails(scr, layout.sessionDetails)
1983 }
1984 }
1985
1986 isOnboarding := m.state == uiOnboarding
1987
1988 // Add status and help layer
1989 m.status.SetHideHelp(isOnboarding)
1990 m.status.Draw(scr, layout.status)
1991
1992 // Draw completions popup if open
1993 if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1994 w, h := m.completions.Size()
1995 x := m.completionsPositionStart.X
1996 y := m.completionsPositionStart.Y - h
1997
1998 screenW := area.Dx()
1999 if x+w > screenW {
2000 x = screenW - w
2001 }
2002 x = max(0, x)
2003 y = max(0, y+1) // Offset for attachments row
2004
2005 completionsView := uv.NewStyledString(m.completions.Render())
2006 completionsView.Draw(scr, image.Rectangle{
2007 Min: image.Pt(x, y),
2008 Max: image.Pt(x+w, y+h),
2009 })
2010 }
2011
2012 // Debugging rendering (visually see when the tui rerenders)
2013 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
2014 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
2015 debug := uv.NewStyledString(debugView.String())
2016 debug.Draw(scr, image.Rectangle{
2017 Min: image.Pt(4, 1),
2018 Max: image.Pt(8, 3),
2019 })
2020 }
2021
2022 // This needs to come last to overlay on top of everything. We always pass
2023 // the full screen bounds because the dialogs will position themselves
2024 // accordingly.
2025 if m.dialog.HasDialogs() {
2026 return m.dialog.Draw(scr, scr.Bounds())
2027 }
2028
2029 switch m.focus {
2030 case uiFocusEditor:
2031 if m.layout.editor.Dy() <= 0 {
2032 // Don't show cursor if editor is not visible
2033 return nil
2034 }
2035 if m.detailsOpen && m.isCompact {
2036 // Don't show cursor if details overlay is open
2037 return nil
2038 }
2039
2040 if m.textarea.Focused() {
2041 cur := m.textarea.Cursor()
2042 cur.X++ // Adjust for app margins
2043 cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2044 return cur
2045 }
2046 }
2047 return nil
2048}
2049
2050// View renders the UI model's view.
2051func (m *UI) View() tea.View {
2052 var v tea.View
2053 v.AltScreen = true
2054 if !m.isTransparent {
2055 v.BackgroundColor = m.com.Styles.Background
2056 }
2057 v.MouseMode = tea.MouseModeCellMotion
2058 v.ReportFocus = m.caps.ReportFocusEvents
2059 v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2060
2061 canvas := uv.NewScreenBuffer(m.width, m.height)
2062 v.Cursor = m.Draw(canvas, canvas.Bounds())
2063
2064 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2065 contentLines := strings.Split(content, "\n")
2066 for i, line := range contentLines {
2067 // Trim trailing spaces for concise rendering
2068 contentLines[i] = strings.TrimRight(line, " ")
2069 }
2070
2071 content = strings.Join(contentLines, "\n")
2072
2073 v.Content = content
2074 if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2075 // HACK: use a random percentage to prevent ghostty from hiding it
2076 // after a timeout.
2077 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2078 }
2079
2080 return v
2081}
2082
2083// ShortHelp implements [help.KeyMap].
2084func (m *UI) ShortHelp() []key.Binding {
2085 var binds []key.Binding
2086 k := &m.keyMap
2087 tab := k.Tab
2088 commands := k.Commands
2089 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2090 commands.SetHelp("/ or ctrl+p", "commands")
2091 }
2092
2093 switch m.state {
2094 case uiInitialize:
2095 binds = append(binds, k.Quit)
2096 case uiChat:
2097 // Show cancel binding if agent is busy.
2098 if m.isAgentBusy() {
2099 cancelBinding := k.Chat.Cancel
2100 if m.isCanceling {
2101 cancelBinding.SetHelp("esc", "press again to cancel")
2102 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2103 cancelBinding.SetHelp("esc", "clear queue")
2104 }
2105 binds = append(binds, cancelBinding)
2106 }
2107
2108 if m.focus == uiFocusEditor {
2109 tab.SetHelp("tab", "focus chat")
2110 } else {
2111 tab.SetHelp("tab", "focus editor")
2112 }
2113
2114 binds = append(binds,
2115 tab,
2116 commands,
2117 k.Models,
2118 )
2119
2120 switch m.focus {
2121 case uiFocusEditor:
2122 binds = append(binds,
2123 k.Editor.Newline,
2124 )
2125 case uiFocusMain:
2126 binds = append(binds,
2127 k.Chat.UpDown,
2128 k.Chat.UpDownOneItem,
2129 k.Chat.PageUp,
2130 k.Chat.PageDown,
2131 k.Chat.Copy,
2132 )
2133 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2134 binds = append(binds, k.Chat.PillLeft)
2135 }
2136 }
2137 default:
2138 // TODO: other states
2139 // if m.session == nil {
2140 // no session selected
2141 binds = append(binds,
2142 commands,
2143 k.Models,
2144 k.Editor.Newline,
2145 )
2146 }
2147
2148 binds = append(binds,
2149 k.Quit,
2150 k.Help,
2151 )
2152
2153 return binds
2154}
2155
2156// FullHelp implements [help.KeyMap].
2157func (m *UI) FullHelp() [][]key.Binding {
2158 var binds [][]key.Binding
2159 k := &m.keyMap
2160 help := k.Help
2161 help.SetHelp("ctrl+g", "less")
2162 hasAttachments := len(m.attachments.List()) > 0
2163 hasSession := m.hasSession()
2164 commands := k.Commands
2165 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2166 commands.SetHelp("/ or ctrl+p", "commands")
2167 }
2168
2169 switch m.state {
2170 case uiInitialize:
2171 binds = append(binds,
2172 []key.Binding{
2173 k.Quit,
2174 })
2175 case uiChat:
2176 // Show cancel binding if agent is busy.
2177 if m.isAgentBusy() {
2178 cancelBinding := k.Chat.Cancel
2179 if m.isCanceling {
2180 cancelBinding.SetHelp("esc", "press again to cancel")
2181 } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2182 cancelBinding.SetHelp("esc", "clear queue")
2183 }
2184 binds = append(binds, []key.Binding{cancelBinding})
2185 }
2186
2187 mainBinds := []key.Binding{}
2188 tab := k.Tab
2189 if m.focus == uiFocusEditor {
2190 tab.SetHelp("tab", "focus chat")
2191 } else {
2192 tab.SetHelp("tab", "focus editor")
2193 }
2194
2195 mainBinds = append(mainBinds,
2196 tab,
2197 commands,
2198 k.Models,
2199 k.Sessions,
2200 )
2201 if hasSession {
2202 mainBinds = append(mainBinds, k.Chat.NewSession)
2203 }
2204
2205 binds = append(binds, mainBinds)
2206
2207 switch m.focus {
2208 case uiFocusEditor:
2209 binds = append(binds,
2210 []key.Binding{
2211 k.Editor.Newline,
2212 k.Editor.AddImage,
2213 k.Editor.PasteImage,
2214 k.Editor.MentionFile,
2215 k.Editor.OpenEditor,
2216 },
2217 )
2218 if hasAttachments {
2219 binds = append(binds,
2220 []key.Binding{
2221 k.Editor.AttachmentDeleteMode,
2222 k.Editor.DeleteAllAttachments,
2223 k.Editor.Escape,
2224 },
2225 )
2226 }
2227 case uiFocusMain:
2228 binds = append(binds,
2229 []key.Binding{
2230 k.Chat.UpDown,
2231 k.Chat.UpDownOneItem,
2232 k.Chat.PageUp,
2233 k.Chat.PageDown,
2234 },
2235 []key.Binding{
2236 k.Chat.HalfPageUp,
2237 k.Chat.HalfPageDown,
2238 k.Chat.Home,
2239 k.Chat.End,
2240 },
2241 []key.Binding{
2242 k.Chat.Copy,
2243 k.Chat.ClearHighlight,
2244 },
2245 )
2246 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2247 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2248 }
2249 }
2250 default:
2251 if m.session == nil {
2252 // no session selected
2253 binds = append(binds,
2254 []key.Binding{
2255 commands,
2256 k.Models,
2257 k.Sessions,
2258 },
2259 []key.Binding{
2260 k.Editor.Newline,
2261 k.Editor.AddImage,
2262 k.Editor.PasteImage,
2263 k.Editor.MentionFile,
2264 k.Editor.OpenEditor,
2265 },
2266 )
2267 if hasAttachments {
2268 binds = append(binds,
2269 []key.Binding{
2270 k.Editor.AttachmentDeleteMode,
2271 k.Editor.DeleteAllAttachments,
2272 k.Editor.Escape,
2273 },
2274 )
2275 }
2276 binds = append(binds,
2277 []key.Binding{
2278 help,
2279 },
2280 )
2281 }
2282 }
2283
2284 binds = append(binds,
2285 []key.Binding{
2286 help,
2287 k.Quit,
2288 },
2289 )
2290
2291 return binds
2292}
2293
2294// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2295func (m *UI) toggleCompactMode() tea.Cmd {
2296 m.forceCompactMode = !m.forceCompactMode
2297
2298 err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2299 if err != nil {
2300 return util.ReportError(err)
2301 }
2302
2303 m.updateLayoutAndSize()
2304
2305 return nil
2306}
2307
2308// updateLayoutAndSize updates the layout and sizes of UI components.
2309func (m *UI) updateLayoutAndSize() {
2310 // Determine if we should be in compact mode
2311 if m.state == uiChat {
2312 if m.forceCompactMode {
2313 m.isCompact = true
2314 return
2315 }
2316 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2317 m.isCompact = true
2318 } else {
2319 m.isCompact = false
2320 }
2321 }
2322
2323 m.layout = m.generateLayout(m.width, m.height)
2324 m.updateSize()
2325}
2326
2327// updateSize updates the sizes of UI components based on the current layout.
2328func (m *UI) updateSize() {
2329 // Set status width
2330 m.status.SetWidth(m.layout.status.Dx())
2331
2332 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2333 m.textarea.SetWidth(m.layout.editor.Dx())
2334 // TODO: Abstract the textarea and attachments into a single editor
2335 // component so we don't have to manually account for the attachments
2336 // height here.
2337 m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2338 m.renderPills()
2339
2340 // Handle different app states
2341 switch m.state {
2342 case uiChat:
2343 if !m.isCompact {
2344 m.cacheSidebarLogo(m.layout.sidebar.Dx())
2345 }
2346 }
2347}
2348
2349// generateLayout calculates the layout rectangles for all UI components based
2350// on the current UI state and terminal dimensions.
2351func (m *UI) generateLayout(w, h int) uiLayout {
2352 // The screen area we're working with
2353 area := image.Rect(0, 0, w, h)
2354
2355 // The help height
2356 helpHeight := 1
2357 // The editor height
2358 editorHeight := 5
2359 // The sidebar width
2360 sidebarWidth := 30
2361 // The header height
2362 const landingHeaderHeight = 4
2363
2364 var helpKeyMap help.KeyMap = m
2365 if m.status != nil && m.status.ShowingAll() {
2366 for _, row := range helpKeyMap.FullHelp() {
2367 helpHeight = max(helpHeight, len(row))
2368 }
2369 }
2370
2371 // Add app margins
2372 appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2373 appRect.Min.Y += 1
2374 appRect.Max.Y -= 1
2375 helpRect.Min.Y -= 1
2376 appRect.Min.X += 1
2377 appRect.Max.X -= 1
2378
2379 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2380 // extra padding on left and right for these states
2381 appRect.Min.X += 1
2382 appRect.Max.X -= 1
2383 }
2384
2385 uiLayout := uiLayout{
2386 area: area,
2387 status: helpRect,
2388 }
2389
2390 // Handle different app states
2391 switch m.state {
2392 case uiOnboarding, uiInitialize:
2393 // Layout
2394 //
2395 // header
2396 // ------
2397 // main
2398 // ------
2399 // help
2400
2401 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2402 uiLayout.header = headerRect
2403 uiLayout.main = mainRect
2404
2405 case uiLanding:
2406 // Layout
2407 //
2408 // header
2409 // ------
2410 // main
2411 // ------
2412 // editor
2413 // ------
2414 // help
2415 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2416 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2417 // Remove extra padding from editor (but keep it for header and main)
2418 editorRect.Min.X -= 1
2419 editorRect.Max.X += 1
2420 uiLayout.header = headerRect
2421 uiLayout.main = mainRect
2422 uiLayout.editor = editorRect
2423
2424 case uiChat:
2425 if m.isCompact {
2426 // Layout
2427 //
2428 // compact-header
2429 // ------
2430 // main
2431 // ------
2432 // editor
2433 // ------
2434 // help
2435 const compactHeaderHeight = 1
2436 headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2437 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2438 sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2439 uiLayout.sessionDetails = sessionDetailsArea
2440 uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2441 // Add one line gap between header and main content
2442 mainRect.Min.Y += 1
2443 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2444 mainRect.Max.X -= 1 // Add padding right
2445 uiLayout.header = headerRect
2446 pillsHeight := m.pillsAreaHeight()
2447 if pillsHeight > 0 {
2448 pillsHeight = min(pillsHeight, mainRect.Dy())
2449 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2450 uiLayout.main = chatRect
2451 uiLayout.pills = pillsRect
2452 } else {
2453 uiLayout.main = mainRect
2454 }
2455 // Add bottom margin to main
2456 uiLayout.main.Max.Y -= 1
2457 uiLayout.editor = editorRect
2458 } else {
2459 // Layout
2460 //
2461 // ------|---
2462 // main |
2463 // ------| side
2464 // editor|
2465 // ----------
2466 // help
2467
2468 mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2469 // Add padding left
2470 sideRect.Min.X += 1
2471 mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2472 mainRect.Max.X -= 1 // Add padding right
2473 uiLayout.sidebar = sideRect
2474 pillsHeight := m.pillsAreaHeight()
2475 if pillsHeight > 0 {
2476 pillsHeight = min(pillsHeight, mainRect.Dy())
2477 chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2478 uiLayout.main = chatRect
2479 uiLayout.pills = pillsRect
2480 } else {
2481 uiLayout.main = mainRect
2482 }
2483 // Add bottom margin to main
2484 uiLayout.main.Max.Y -= 1
2485 uiLayout.editor = editorRect
2486 }
2487 }
2488
2489 return uiLayout
2490}
2491
2492// uiLayout defines the positioning of UI elements.
2493type uiLayout struct {
2494 // area is the overall available area.
2495 area uv.Rectangle
2496
2497 // header is the header shown in special cases
2498 // e.x when the sidebar is collapsed
2499 // or when in the landing page
2500 // or in init/config
2501 header uv.Rectangle
2502
2503 // main is the area for the main pane. (e.x chat, configure, landing)
2504 main uv.Rectangle
2505
2506 // pills is the area for the pills panel.
2507 pills uv.Rectangle
2508
2509 // editor is the area for the editor pane.
2510 editor uv.Rectangle
2511
2512 // sidebar is the area for the sidebar.
2513 sidebar uv.Rectangle
2514
2515 // status is the area for the status view.
2516 status uv.Rectangle
2517
2518 // session details is the area for the session details overlay in compact mode.
2519 sessionDetails uv.Rectangle
2520}
2521
2522func (m *UI) openEditor(value string) tea.Cmd {
2523 tmpfile, err := os.CreateTemp("", "msg_*.md")
2524 if err != nil {
2525 return util.ReportError(err)
2526 }
2527 defer tmpfile.Close() //nolint:errcheck
2528 if _, err := tmpfile.WriteString(value); err != nil {
2529 return util.ReportError(err)
2530 }
2531 cmd, err := editor.Command(
2532 "crush",
2533 tmpfile.Name(),
2534 editor.AtPosition(
2535 m.textarea.Line()+1,
2536 m.textarea.Column()+1,
2537 ),
2538 )
2539 if err != nil {
2540 return util.ReportError(err)
2541 }
2542 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2543 if err != nil {
2544 return util.ReportError(err)
2545 }
2546 content, err := os.ReadFile(tmpfile.Name())
2547 if err != nil {
2548 return util.ReportError(err)
2549 }
2550 if len(content) == 0 {
2551 return util.ReportWarn("Message is empty")
2552 }
2553 os.Remove(tmpfile.Name())
2554 return openEditorMsg{
2555 Text: strings.TrimSpace(string(content)),
2556 }
2557 })
2558}
2559
2560// setEditorPrompt configures the textarea prompt function based on whether
2561// yolo mode is enabled.
2562func (m *UI) setEditorPrompt(yolo bool) {
2563 if yolo {
2564 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2565 return
2566 }
2567 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2568}
2569
2570// normalPromptFunc returns the normal editor prompt style (" > " on first
2571// line, "::: " on subsequent lines).
2572func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2573 t := m.com.Styles
2574 if info.LineNumber == 0 {
2575 if info.Focused {
2576 return " > "
2577 }
2578 return "::: "
2579 }
2580 if info.Focused {
2581 return t.EditorPromptNormalFocused.Render()
2582 }
2583 return t.EditorPromptNormalBlurred.Render()
2584}
2585
2586// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2587// and colored dots.
2588func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2589 t := m.com.Styles
2590 if info.LineNumber == 0 {
2591 if info.Focused {
2592 return t.EditorPromptYoloIconFocused.Render()
2593 } else {
2594 return t.EditorPromptYoloIconBlurred.Render()
2595 }
2596 }
2597 if info.Focused {
2598 return t.EditorPromptYoloDotsFocused.Render()
2599 }
2600 return t.EditorPromptYoloDotsBlurred.Render()
2601}
2602
2603// closeCompletions closes the completions popup and resets state.
2604func (m *UI) closeCompletions() {
2605 m.completionsOpen = false
2606 m.completionsQuery = ""
2607 m.completionsStartIndex = 0
2608 m.completions.Close()
2609}
2610
2611// insertCompletionText replaces the @query in the textarea with the given text.
2612// Returns false if the replacement cannot be performed.
2613func (m *UI) insertCompletionText(text string) bool {
2614 value := m.textarea.Value()
2615 if m.completionsStartIndex > len(value) {
2616 return false
2617 }
2618
2619 word := m.textareaWord()
2620 endIdx := min(m.completionsStartIndex+len(word), len(value))
2621 newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2622 m.textarea.SetValue(newValue)
2623 m.textarea.MoveToEnd()
2624 m.textarea.InsertRune(' ')
2625 return true
2626}
2627
2628// insertFileCompletion inserts the selected file path into the textarea,
2629// replacing the @query, and adds the file as an attachment.
2630func (m *UI) insertFileCompletion(path string) tea.Cmd {
2631 if !m.insertCompletionText(path) {
2632 return nil
2633 }
2634
2635 return func() tea.Msg {
2636 absPath, _ := filepath.Abs(path)
2637
2638 if m.hasSession() {
2639 // Skip attachment if file was already read and hasn't been modified.
2640 lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2641 if !lastRead.IsZero() {
2642 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2643 return nil
2644 }
2645 }
2646 } else if slices.Contains(m.sessionFileReads, absPath) {
2647 return nil
2648 }
2649
2650 m.sessionFileReads = append(m.sessionFileReads, absPath)
2651
2652 // Add file as attachment.
2653 content, err := os.ReadFile(path)
2654 if err != nil {
2655 // If it fails, let the LLM handle it later.
2656 return nil
2657 }
2658
2659 return message.Attachment{
2660 FilePath: path,
2661 FileName: filepath.Base(path),
2662 MimeType: mimeOf(content),
2663 Content: content,
2664 }
2665 }
2666}
2667
2668// insertMCPResourceCompletion inserts the selected resource into the textarea,
2669// replacing the @query, and adds the resource as an attachment.
2670func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2671 displayText := cmp.Or(item.Title, item.URI)
2672
2673 if !m.insertCompletionText(displayText) {
2674 return nil
2675 }
2676
2677 return func() tea.Msg {
2678 contents, err := m.com.Workspace.ReadMCPResource(
2679 context.Background(),
2680 item.MCPName,
2681 item.URI,
2682 )
2683 if err != nil {
2684 slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2685 return nil
2686 }
2687 if len(contents) == 0 {
2688 return nil
2689 }
2690
2691 content := contents[0]
2692 var data []byte
2693 if content.Text != "" {
2694 data = []byte(content.Text)
2695 } else if len(content.Blob) > 0 {
2696 data = content.Blob
2697 }
2698 if len(data) == 0 {
2699 return nil
2700 }
2701
2702 mimeType := item.MIMEType
2703 if mimeType == "" && content.MIMEType != "" {
2704 mimeType = content.MIMEType
2705 }
2706 if mimeType == "" {
2707 mimeType = "text/plain"
2708 }
2709
2710 return message.Attachment{
2711 FilePath: item.URI,
2712 FileName: displayText,
2713 MimeType: mimeType,
2714 Content: data,
2715 }
2716 }
2717}
2718
2719// completionsPosition returns the X and Y position for the completions popup.
2720func (m *UI) completionsPosition() image.Point {
2721 cur := m.textarea.Cursor()
2722 if cur == nil {
2723 return image.Point{
2724 X: m.layout.editor.Min.X,
2725 Y: m.layout.editor.Min.Y,
2726 }
2727 }
2728 return image.Point{
2729 X: cur.X + m.layout.editor.Min.X,
2730 Y: m.layout.editor.Min.Y + cur.Y,
2731 }
2732}
2733
2734// textareaWord returns the current word at the cursor position.
2735func (m *UI) textareaWord() string {
2736 return m.textarea.Word()
2737}
2738
2739// isWhitespace returns true if the byte is a whitespace character.
2740func isWhitespace(b byte) bool {
2741 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2742}
2743
2744// isAgentBusy returns true if the agent coordinator exists and is currently
2745// busy processing a request.
2746func (m *UI) isAgentBusy() bool {
2747 return m.com.Workspace.AgentIsReady() &&
2748 m.com.Workspace.AgentIsBusy()
2749}
2750
2751// hasSession returns true if there is an active session with a valid ID.
2752func (m *UI) hasSession() bool {
2753 return m.session != nil && m.session.ID != ""
2754}
2755
2756// mimeOf detects the MIME type of the given content.
2757func mimeOf(content []byte) string {
2758 mimeBufferSize := min(512, len(content))
2759 return http.DetectContentType(content[:mimeBufferSize])
2760}
2761
2762var readyPlaceholders = [...]string{
2763 "Ready!",
2764 "Ready...",
2765 "Ready?",
2766 "Ready for instructions",
2767}
2768
2769var workingPlaceholders = [...]string{
2770 "Working!",
2771 "Working...",
2772 "Brrrrr...",
2773 "Prrrrrrrr...",
2774 "Processing...",
2775 "Thinking...",
2776}
2777
2778// randomizePlaceholders selects random placeholder text for the textarea's
2779// ready and working states.
2780func (m *UI) randomizePlaceholders() {
2781 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2782 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2783}
2784
2785// renderEditorView renders the editor view with attachments if any.
2786func (m *UI) renderEditorView(width int) string {
2787 var attachmentsView string
2788 if len(m.attachments.List()) > 0 {
2789 attachmentsView = m.attachments.Render(width)
2790 }
2791 return strings.Join([]string{
2792 attachmentsView,
2793 m.textarea.View(),
2794 "", // margin at bottom of editor
2795 }, "\n")
2796}
2797
2798// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2799func (m *UI) cacheSidebarLogo(width int) {
2800 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2801}
2802
2803// sendMessage sends a message with the given content and attachments.
2804func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2805 if !m.com.Workspace.AgentIsReady() {
2806 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2807 }
2808
2809 var cmds []tea.Cmd
2810 if !m.hasSession() {
2811 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
2812 if err != nil {
2813 return util.ReportError(err)
2814 }
2815 if m.forceCompactMode {
2816 m.isCompact = true
2817 }
2818 if newSession.ID != "" {
2819 m.session = &newSession
2820 cmds = append(cmds, m.loadSession(newSession.ID))
2821 }
2822 m.setState(uiChat, m.focus)
2823 }
2824
2825 ctx := context.Background()
2826 cmds = append(cmds, func() tea.Msg {
2827 for _, path := range m.sessionFileReads {
2828 m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2829 m.com.Workspace.LSPStart(ctx, path)
2830 }
2831 return nil
2832 })
2833
2834 // Capture session ID to avoid race with main goroutine updating m.session.
2835 sessionID := m.session.ID
2836 cmds = append(cmds, func() tea.Msg {
2837 err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
2838 if err != nil {
2839 isCancelErr := errors.Is(err, context.Canceled)
2840 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2841 if isCancelErr || isPermissionErr {
2842 return nil
2843 }
2844 return util.InfoMsg{
2845 Type: util.InfoTypeError,
2846 Msg: fmt.Sprintf("Failed to run agent: %v", err),
2847 }
2848 }
2849 return nil
2850 })
2851 return tea.Batch(cmds...)
2852}
2853
2854const cancelTimerDuration = 2 * time.Second
2855
2856// cancelTimerCmd creates a command that expires the cancel timer.
2857func cancelTimerCmd() tea.Cmd {
2858 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2859 return cancelTimerExpiredMsg{}
2860 })
2861}
2862
2863// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2864// and starts a timer. The second press (before the timer expires) actually
2865// cancels the agent.
2866func (m *UI) cancelAgent() tea.Cmd {
2867 if !m.hasSession() {
2868 return nil
2869 }
2870
2871 if !m.com.Workspace.AgentIsReady() {
2872 return nil
2873 }
2874
2875 if m.isCanceling {
2876 // Second escape press - actually cancel the agent.
2877 m.isCanceling = false
2878 m.com.Workspace.AgentCancel(m.session.ID)
2879 // Stop the spinning todo indicator.
2880 m.todoIsSpinning = false
2881 m.renderPills()
2882 return nil
2883 }
2884
2885 // Check if there are queued prompts - if so, clear the queue.
2886 if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2887 m.com.Workspace.AgentClearQueue(m.session.ID)
2888 return nil
2889 }
2890
2891 // First escape press - set canceling state and start timer.
2892 m.isCanceling = true
2893 return cancelTimerCmd()
2894}
2895
2896// openDialog opens a dialog by its ID.
2897func (m *UI) openDialog(id string) tea.Cmd {
2898 var cmds []tea.Cmd
2899 switch id {
2900 case dialog.SessionsID:
2901 if cmd := m.openSessionsDialog(); cmd != nil {
2902 cmds = append(cmds, cmd)
2903 }
2904 case dialog.ModelsID:
2905 if cmd := m.openModelsDialog(); cmd != nil {
2906 cmds = append(cmds, cmd)
2907 }
2908 case dialog.CommandsID:
2909 if cmd := m.openCommandsDialog(); cmd != nil {
2910 cmds = append(cmds, cmd)
2911 }
2912 case dialog.ReasoningID:
2913 if cmd := m.openReasoningDialog(); cmd != nil {
2914 cmds = append(cmds, cmd)
2915 }
2916 case dialog.QuitID:
2917 if cmd := m.openQuitDialog(); cmd != nil {
2918 cmds = append(cmds, cmd)
2919 }
2920 default:
2921 // Unknown dialog
2922 break
2923 }
2924 return tea.Batch(cmds...)
2925}
2926
2927// openQuitDialog opens the quit confirmation dialog.
2928func (m *UI) openQuitDialog() tea.Cmd {
2929 if m.dialog.ContainsDialog(dialog.QuitID) {
2930 // Bring to front
2931 m.dialog.BringToFront(dialog.QuitID)
2932 return nil
2933 }
2934
2935 quitDialog := dialog.NewQuit(m.com)
2936 m.dialog.OpenDialog(quitDialog)
2937 return nil
2938}
2939
2940// openModelsDialog opens the models dialog.
2941func (m *UI) openModelsDialog() tea.Cmd {
2942 if m.dialog.ContainsDialog(dialog.ModelsID) {
2943 // Bring to front
2944 m.dialog.BringToFront(dialog.ModelsID)
2945 return nil
2946 }
2947
2948 isOnboarding := m.state == uiOnboarding
2949 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2950 if err != nil {
2951 return util.ReportError(err)
2952 }
2953
2954 m.dialog.OpenDialog(modelsDialog)
2955
2956 return nil
2957}
2958
2959// openCommandsDialog opens the commands dialog.
2960func (m *UI) openCommandsDialog() tea.Cmd {
2961 if m.dialog.ContainsDialog(dialog.CommandsID) {
2962 // Bring to front
2963 m.dialog.BringToFront(dialog.CommandsID)
2964 return nil
2965 }
2966
2967 var sessionID string
2968 hasSession := m.session != nil
2969 if hasSession {
2970 sessionID = m.session.ID
2971 }
2972 hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
2973 hasQueue := m.promptQueue > 0
2974
2975 commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
2976 if err != nil {
2977 return util.ReportError(err)
2978 }
2979
2980 m.dialog.OpenDialog(commands)
2981
2982 return nil
2983}
2984
2985// openReasoningDialog opens the reasoning effort dialog.
2986func (m *UI) openReasoningDialog() tea.Cmd {
2987 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2988 m.dialog.BringToFront(dialog.ReasoningID)
2989 return nil
2990 }
2991
2992 reasoningDialog, err := dialog.NewReasoning(m.com)
2993 if err != nil {
2994 return util.ReportError(err)
2995 }
2996
2997 m.dialog.OpenDialog(reasoningDialog)
2998 return nil
2999}
3000
3001// openSessionsDialog opens the sessions dialog. If the dialog is already open,
3002// it brings it to the front. Otherwise, it will list all the sessions and open
3003// the dialog.
3004func (m *UI) openSessionsDialog() tea.Cmd {
3005 if m.dialog.ContainsDialog(dialog.SessionsID) {
3006 // Bring to front
3007 m.dialog.BringToFront(dialog.SessionsID)
3008 return nil
3009 }
3010
3011 selectedSessionID := ""
3012 if m.session != nil {
3013 selectedSessionID = m.session.ID
3014 }
3015
3016 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
3017 if err != nil {
3018 return util.ReportError(err)
3019 }
3020
3021 m.dialog.OpenDialog(dialog)
3022 return nil
3023}
3024
3025// openFilesDialog opens the file picker dialog.
3026func (m *UI) openFilesDialog() tea.Cmd {
3027 if m.dialog.ContainsDialog(dialog.FilePickerID) {
3028 // Bring to front
3029 m.dialog.BringToFront(dialog.FilePickerID)
3030 return nil
3031 }
3032
3033 filePicker, cmd := dialog.NewFilePicker(m.com)
3034 filePicker.SetImageCapabilities(&m.caps)
3035 m.dialog.OpenDialog(filePicker)
3036
3037 return cmd
3038}
3039
3040// openPermissionsDialog opens the permissions dialog for a permission request.
3041func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3042 // Close any existing permissions dialog first.
3043 m.dialog.CloseDialog(dialog.PermissionsID)
3044
3045 // Get diff mode from config.
3046 var opts []dialog.PermissionsOption
3047 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3048 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3049 }
3050
3051 permDialog := dialog.NewPermissions(m.com, perm, opts...)
3052 m.dialog.OpenDialog(permDialog)
3053 return nil
3054}
3055
3056// handlePermissionNotification updates tool items when permission state changes.
3057func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3058 toolItem := m.chat.MessageItem(notification.ToolCallID)
3059 if toolItem == nil {
3060 return
3061 }
3062
3063 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3064 if notification.Granted {
3065 permItem.SetStatus(chat.ToolStatusRunning)
3066 } else {
3067 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3068 }
3069 }
3070}
3071
3072// handleAgentNotification translates domain agent events into desktop
3073// notifications using the UI notification backend.
3074func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3075 switch n.Type {
3076 case notify.TypeAgentFinished:
3077 return m.sendNotification(notification.Notification{
3078 Title: "Crush is waiting...",
3079 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3080 })
3081 default:
3082 return nil
3083 }
3084}
3085
3086// newSession clears the current session state and prepares for a new session.
3087// The actual session creation happens when the user sends their first message.
3088// Returns a command to reload prompt history.
3089func (m *UI) newSession() tea.Cmd {
3090 if !m.hasSession() {
3091 return nil
3092 }
3093
3094 m.session = nil
3095 m.sessionFiles = nil
3096 m.sessionFileReads = nil
3097 m.setState(uiLanding, uiFocusEditor)
3098 m.textarea.Focus()
3099 m.chat.Blur()
3100 m.chat.ClearMessages()
3101 m.pillsExpanded = false
3102 m.promptQueue = 0
3103 m.pillsView = ""
3104 m.historyReset()
3105 agenttools.ResetCache()
3106 return tea.Batch(
3107 func() tea.Msg {
3108 m.com.Workspace.LSPStopAll(context.Background())
3109 return nil
3110 },
3111 m.loadPromptHistory(),
3112 )
3113}
3114
3115// handlePasteMsg handles a paste message.
3116func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3117 if m.dialog.HasDialogs() {
3118 return m.handleDialogMsg(msg)
3119 }
3120
3121 if m.focus != uiFocusEditor {
3122 return nil
3123 }
3124
3125 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3126 return func() tea.Msg {
3127 content := []byte(msg.Content)
3128 if int64(len(content)) > common.MaxAttachmentSize {
3129 return util.ReportWarn("Paste is too big (>5mb)")
3130 }
3131 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3132 mimeBufferSize := min(512, len(content))
3133 mimeType := http.DetectContentType(content[:mimeBufferSize])
3134 return message.Attachment{
3135 FileName: name,
3136 FilePath: name,
3137 MimeType: mimeType,
3138 Content: content,
3139 }
3140 }
3141 }
3142
3143 // Attempt to parse pasted content as file paths. If possible to parse,
3144 // all files exist and are valid, add as attachments.
3145 // Otherwise, paste as text.
3146 paths := fsext.ParsePastedFiles(msg.Content)
3147 allExistsAndValid := func() bool {
3148 if len(paths) == 0 {
3149 return false
3150 }
3151 for _, path := range paths {
3152 if _, err := os.Stat(path); os.IsNotExist(err) {
3153 return false
3154 }
3155
3156 lowerPath := strings.ToLower(path)
3157 isValid := false
3158 for _, ext := range common.AllowedImageTypes {
3159 if strings.HasSuffix(lowerPath, ext) {
3160 isValid = true
3161 break
3162 }
3163 }
3164 if !isValid {
3165 return false
3166 }
3167 }
3168 return true
3169 }
3170 if !allExistsAndValid() {
3171 var cmd tea.Cmd
3172 m.textarea, cmd = m.textarea.Update(msg)
3173 return cmd
3174 }
3175
3176 var cmds []tea.Cmd
3177 for _, path := range paths {
3178 cmds = append(cmds, m.handleFilePathPaste(path))
3179 }
3180 return tea.Batch(cmds...)
3181}
3182
3183// handleFilePathPaste handles a pasted file path.
3184func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3185 return func() tea.Msg {
3186 fileInfo, err := os.Stat(path)
3187 if err != nil {
3188 return util.ReportError(err)
3189 }
3190 if fileInfo.IsDir() {
3191 return util.ReportWarn("Cannot attach a directory")
3192 }
3193 if fileInfo.Size() > common.MaxAttachmentSize {
3194 return util.ReportWarn("File is too big (>5mb)")
3195 }
3196
3197 content, err := os.ReadFile(path)
3198 if err != nil {
3199 return util.ReportError(err)
3200 }
3201
3202 mimeBufferSize := min(512, len(content))
3203 mimeType := http.DetectContentType(content[:mimeBufferSize])
3204 fileName := filepath.Base(path)
3205 return message.Attachment{
3206 FilePath: path,
3207 FileName: fileName,
3208 MimeType: mimeType,
3209 Content: content,
3210 }
3211 }
3212}
3213
3214// pasteImageFromClipboard reads image data from the system clipboard and
3215// creates an attachment. If no image data is found, it falls back to
3216// interpreting clipboard text as a file path.
3217func (m *UI) pasteImageFromClipboard() tea.Msg {
3218 imageData, err := readClipboard(clipboardFormatImage)
3219 if int64(len(imageData)) > common.MaxAttachmentSize {
3220 return util.InfoMsg{
3221 Type: util.InfoTypeError,
3222 Msg: "File too large, max 5MB",
3223 }
3224 }
3225 name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3226 if err == nil {
3227 return message.Attachment{
3228 FilePath: name,
3229 FileName: name,
3230 MimeType: mimeOf(imageData),
3231 Content: imageData,
3232 }
3233 }
3234
3235 textData, textErr := readClipboard(clipboardFormatText)
3236 if textErr != nil || len(textData) == 0 {
3237 return nil // Clipboard is empty or does not contain an image
3238 }
3239
3240 path := strings.TrimSpace(string(textData))
3241 path = strings.ReplaceAll(path, "\\ ", " ")
3242 if _, statErr := os.Stat(path); statErr != nil {
3243 return nil // Clipboard does not contain an image or valid file path
3244 }
3245
3246 lowerPath := strings.ToLower(path)
3247 isAllowed := false
3248 for _, ext := range common.AllowedImageTypes {
3249 if strings.HasSuffix(lowerPath, ext) {
3250 isAllowed = true
3251 break
3252 }
3253 }
3254 if !isAllowed {
3255 return util.NewInfoMsg("File type is not a supported image format")
3256 }
3257
3258 fileInfo, statErr := os.Stat(path)
3259 if statErr != nil {
3260 return util.InfoMsg{
3261 Type: util.InfoTypeError,
3262 Msg: fmt.Sprintf("Unable to read file: %v", statErr),
3263 }
3264 }
3265 if fileInfo.Size() > common.MaxAttachmentSize {
3266 return util.InfoMsg{
3267 Type: util.InfoTypeError,
3268 Msg: "File too large, max 5MB",
3269 }
3270 }
3271
3272 content, readErr := os.ReadFile(path)
3273 if readErr != nil {
3274 return util.InfoMsg{
3275 Type: util.InfoTypeError,
3276 Msg: fmt.Sprintf("Unable to read file: %v", readErr),
3277 }
3278 }
3279
3280 return message.Attachment{
3281 FilePath: path,
3282 FileName: filepath.Base(path),
3283 MimeType: mimeOf(content),
3284 Content: content,
3285 }
3286}
3287
3288var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3289
3290func (m *UI) pasteIdx() int {
3291 result := 0
3292 for _, at := range m.attachments.List() {
3293 found := pasteRE.FindStringSubmatch(at.FileName)
3294 if len(found) == 0 {
3295 continue
3296 }
3297 idx, err := strconv.Atoi(found[1])
3298 if err == nil {
3299 result = max(result, idx)
3300 }
3301 }
3302 return result + 1
3303}
3304
3305// drawSessionDetails draws the session details in compact mode.
3306func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3307 if m.session == nil {
3308 return
3309 }
3310
3311 s := m.com.Styles
3312
3313 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3314 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3315
3316 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3317 blocks := []string{
3318 title,
3319 "",
3320 m.modelInfo(width),
3321 "",
3322 }
3323
3324 detailsHeader := lipgloss.JoinVertical(
3325 lipgloss.Left,
3326 blocks...,
3327 )
3328
3329 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3330
3331 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3332
3333 const maxSectionWidth = 50
3334 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3335 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
3336
3337 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3338 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3339 filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3340 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3341 uv.NewStyledString(
3342 s.CompactDetails.View.
3343 Width(area.Dx()).
3344 Render(
3345 lipgloss.JoinVertical(
3346 lipgloss.Left,
3347 detailsHeader,
3348 sections,
3349 version,
3350 ),
3351 ),
3352 ).Draw(scr, area)
3353}
3354
3355func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3356 load := func() tea.Msg {
3357 prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3358 if err != nil {
3359 // TODO: make this better
3360 return util.ReportError(err)()
3361 }
3362
3363 if prompt == "" {
3364 return nil
3365 }
3366 return sendMessageMsg{
3367 Content: prompt,
3368 }
3369 }
3370
3371 var cmds []tea.Cmd
3372 if cmd := m.dialog.StartLoading(); cmd != nil {
3373 cmds = append(cmds, cmd)
3374 }
3375 cmds = append(cmds, load, func() tea.Msg {
3376 return closeDialogMsg{}
3377 })
3378
3379 return tea.Sequence(cmds...)
3380}
3381
3382func (m *UI) handleStateChanged() tea.Cmd {
3383 return func() tea.Msg {
3384 m.com.Workspace.UpdateAgentModel(context.Background())
3385 return mcpStateChangedMsg{
3386 states: m.com.Workspace.MCPGetStates(),
3387 }
3388 }
3389}
3390
3391func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3392 return func() tea.Msg {
3393 ws.MCPRefreshPrompts(context.Background(), name)
3394 return nil
3395 }
3396}
3397
3398func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3399 return func() tea.Msg {
3400 ws.RefreshMCPTools(context.Background(), name)
3401 return nil
3402 }
3403}
3404
3405func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3406 return func() tea.Msg {
3407 ws.MCPRefreshResources(context.Background(), name)
3408 return nil
3409 }
3410}
3411
3412func (m *UI) copyChatHighlight() tea.Cmd {
3413 text := m.chat.HighlightContent()
3414 return common.CopyToClipboardWithCallback(
3415 text,
3416 "Selected text copied to clipboard",
3417 func() tea.Msg {
3418 m.chat.ClearMouse()
3419 return nil
3420 },
3421 )
3422}
3423
3424// renderLogo renders the Crush logo with the given styles and dimensions.
3425func renderLogo(t *styles.Styles, compact bool, width int) string {
3426 return logo.Render(t, version.Version, compact, logo.Opts{
3427 FieldColor: t.LogoFieldColor,
3428 TitleColorA: t.LogoTitleColorA,
3429 TitleColorB: t.LogoTitleColorB,
3430 CharmColor: t.LogoCharmColor,
3431 VersionColor: t.LogoVersionColor,
3432 Width: width,
3433 })
3434}