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