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