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