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