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