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