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