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