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