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