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