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