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