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