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