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