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