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