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