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 case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1546 if cmd := m.openCommandsDialog(); cmd != nil {
1547 cmds = append(cmds, cmd)
1548 }
1549 default:
1550 if handleGlobalKeys(msg) {
1551 // Handle global keys first before passing to textarea.
1552 break
1553 }
1554
1555 // Check for @ trigger before passing to textarea.
1556 curValue := m.textarea.Value()
1557 curIdx := len(curValue)
1558
1559 // Trigger completions on @.
1560 if msg.String() == "@" && !m.completionsOpen {
1561 // Only show if beginning of prompt or after whitespace.
1562 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1563 m.completionsOpen = true
1564 m.completionsQuery = ""
1565 m.completionsStartIndex = curIdx
1566 m.completionsPositionStart = m.completionsPosition()
1567 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1568 cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1569 }
1570 }
1571
1572 // remove the details if they are open when user starts typing
1573 if m.detailsOpen {
1574 m.detailsOpen = false
1575 m.updateLayoutAndSize()
1576 }
1577
1578 ta, cmd := m.textarea.Update(msg)
1579 m.textarea = ta
1580 cmds = append(cmds, cmd)
1581
1582 // Any text modification becomes the current draft.
1583 m.updateHistoryDraft(curValue)
1584
1585 // After updating textarea, check if we need to filter completions.
1586 // Skip filtering on the initial @ keystroke since items are loading async.
1587 if m.completionsOpen && msg.String() != "@" {
1588 newValue := m.textarea.Value()
1589 newIdx := len(newValue)
1590
1591 // Close completions if cursor moved before start.
1592 if newIdx <= m.completionsStartIndex {
1593 m.closeCompletions()
1594 } else if msg.String() == "space" {
1595 // Close on space.
1596 m.closeCompletions()
1597 } else {
1598 // Extract current word and filter.
1599 word := m.textareaWord()
1600 if strings.HasPrefix(word, "@") {
1601 m.completionsQuery = word[1:]
1602 m.completions.Filter(m.completionsQuery)
1603 } else if m.completionsOpen {
1604 m.closeCompletions()
1605 }
1606 }
1607 }
1608 }
1609 case uiFocusMain:
1610 switch {
1611 case key.Matches(msg, m.keyMap.Tab):
1612 m.focus = uiFocusEditor
1613 cmds = append(cmds, m.textarea.Focus())
1614 m.chat.Blur()
1615 case key.Matches(msg, m.keyMap.Chat.NewSession):
1616 if !m.hasSession() {
1617 break
1618 }
1619 if m.isAgentBusy() {
1620 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1621 break
1622 }
1623 m.focus = uiFocusEditor
1624 if cmd := m.newSession(); cmd != nil {
1625 cmds = append(cmds, cmd)
1626 }
1627 case key.Matches(msg, m.keyMap.Chat.Expand):
1628 m.chat.ToggleExpandedSelectedItem()
1629 case key.Matches(msg, m.keyMap.Chat.Up):
1630 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1631 cmds = append(cmds, cmd)
1632 }
1633 if !m.chat.SelectedItemInView() {
1634 m.chat.SelectPrev()
1635 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1636 cmds = append(cmds, cmd)
1637 }
1638 }
1639 case key.Matches(msg, m.keyMap.Chat.Down):
1640 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1641 cmds = append(cmds, cmd)
1642 }
1643 if !m.chat.SelectedItemInView() {
1644 m.chat.SelectNext()
1645 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1646 cmds = append(cmds, cmd)
1647 }
1648 }
1649 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1650 m.chat.SelectPrev()
1651 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1652 cmds = append(cmds, cmd)
1653 }
1654 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1655 m.chat.SelectNext()
1656 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1657 cmds = append(cmds, cmd)
1658 }
1659 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1660 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1661 cmds = append(cmds, cmd)
1662 }
1663 m.chat.SelectFirstInView()
1664 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1665 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1666 cmds = append(cmds, cmd)
1667 }
1668 m.chat.SelectLastInView()
1669 case key.Matches(msg, m.keyMap.Chat.PageUp):
1670 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1671 cmds = append(cmds, cmd)
1672 }
1673 m.chat.SelectFirstInView()
1674 case key.Matches(msg, m.keyMap.Chat.PageDown):
1675 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1676 cmds = append(cmds, cmd)
1677 }
1678 m.chat.SelectLastInView()
1679 case key.Matches(msg, m.keyMap.Chat.Home):
1680 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1681 cmds = append(cmds, cmd)
1682 }
1683 m.chat.SelectFirst()
1684 case key.Matches(msg, m.keyMap.Chat.End):
1685 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1686 cmds = append(cmds, cmd)
1687 }
1688 m.chat.SelectLast()
1689 default:
1690 if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1691 cmds = append(cmds, cmd)
1692 } else {
1693 handleGlobalKeys(msg)
1694 }
1695 }
1696 default:
1697 handleGlobalKeys(msg)
1698 }
1699 default:
1700 handleGlobalKeys(msg)
1701 }
1702
1703 return tea.Batch(cmds...)
1704}
1705
1706// Draw implements [uv.Drawable] and draws the UI model.
1707func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1708 layout := m.generateLayout(area.Dx(), area.Dy())
1709
1710 if m.layout != layout {
1711 m.layout = layout
1712 m.updateSize()
1713 }
1714
1715 // Clear the screen first
1716 screen.Clear(scr)
1717
1718 switch m.state {
1719 case uiOnboarding:
1720 header := uv.NewStyledString(m.header)
1721 header.Draw(scr, layout.header)
1722
1723 // NOTE: Onboarding flow will be rendered as dialogs below, but
1724 // positioned at the bottom left of the screen.
1725
1726 case uiInitialize:
1727 header := uv.NewStyledString(m.header)
1728 header.Draw(scr, layout.header)
1729
1730 main := uv.NewStyledString(m.initializeView())
1731 main.Draw(scr, layout.main)
1732
1733 case uiLanding:
1734 header := uv.NewStyledString(m.header)
1735 header.Draw(scr, layout.header)
1736 main := uv.NewStyledString(m.landingView())
1737 main.Draw(scr, layout.main)
1738
1739 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1740 editor.Draw(scr, layout.editor)
1741
1742 case uiChat:
1743 if m.isCompact {
1744 header := uv.NewStyledString(m.header)
1745 header.Draw(scr, layout.header)
1746 } else {
1747 m.drawSidebar(scr, layout.sidebar)
1748 }
1749
1750 m.chat.Draw(scr, layout.main)
1751 if layout.pills.Dy() > 0 && m.pillsView != "" {
1752 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1753 }
1754
1755 editorWidth := scr.Bounds().Dx()
1756 if !m.isCompact {
1757 editorWidth -= layout.sidebar.Dx()
1758 }
1759 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1760 editor.Draw(scr, layout.editor)
1761
1762 // Draw details overlay in compact mode when open
1763 if m.isCompact && m.detailsOpen {
1764 m.drawSessionDetails(scr, layout.sessionDetails)
1765 }
1766 }
1767
1768 isOnboarding := m.state == uiOnboarding
1769
1770 // Add status and help layer
1771 m.status.SetHideHelp(isOnboarding)
1772 m.status.Draw(scr, layout.status)
1773
1774 // Draw completions popup if open
1775 if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1776 w, h := m.completions.Size()
1777 x := m.completionsPositionStart.X
1778 y := m.completionsPositionStart.Y - h
1779
1780 screenW := area.Dx()
1781 if x+w > screenW {
1782 x = screenW - w
1783 }
1784 x = max(0, x)
1785 y = max(0, y)
1786
1787 completionsView := uv.NewStyledString(m.completions.Render())
1788 completionsView.Draw(scr, image.Rectangle{
1789 Min: image.Pt(x, y),
1790 Max: image.Pt(x+w, y+h),
1791 })
1792 }
1793
1794 // Debugging rendering (visually see when the tui rerenders)
1795 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1796 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1797 debug := uv.NewStyledString(debugView.String())
1798 debug.Draw(scr, image.Rectangle{
1799 Min: image.Pt(4, 1),
1800 Max: image.Pt(8, 3),
1801 })
1802 }
1803
1804 // This needs to come last to overlay on top of everything. We always pass
1805 // the full screen bounds because the dialogs will position themselves
1806 // accordingly.
1807 if m.dialog.HasDialogs() {
1808 return m.dialog.Draw(scr, scr.Bounds())
1809 }
1810
1811 switch m.focus {
1812 case uiFocusEditor:
1813 if m.layout.editor.Dy() <= 0 {
1814 // Don't show cursor if editor is not visible
1815 return nil
1816 }
1817 if m.detailsOpen && m.isCompact {
1818 // Don't show cursor if details overlay is open
1819 return nil
1820 }
1821
1822 if m.textarea.Focused() {
1823 cur := m.textarea.Cursor()
1824 cur.X++ // Adjust for app margins
1825 cur.Y += m.layout.editor.Min.Y
1826 // Offset for attachment row if present.
1827 if len(m.attachments.List()) > 0 {
1828 cur.Y++
1829 }
1830 return cur
1831 }
1832 }
1833 return nil
1834}
1835
1836// View renders the UI model's view.
1837func (m *UI) View() tea.View {
1838 var v tea.View
1839 v.AltScreen = true
1840 v.BackgroundColor = m.com.Styles.Background
1841 v.MouseMode = tea.MouseModeCellMotion
1842 v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
1843
1844 canvas := uv.NewScreenBuffer(m.width, m.height)
1845 v.Cursor = m.Draw(canvas, canvas.Bounds())
1846
1847 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1848 contentLines := strings.Split(content, "\n")
1849 for i, line := range contentLines {
1850 // Trim trailing spaces for concise rendering
1851 contentLines[i] = strings.TrimRight(line, " ")
1852 }
1853
1854 content = strings.Join(contentLines, "\n")
1855
1856 v.Content = content
1857 if m.sendProgressBar && m.isAgentBusy() {
1858 // HACK: use a random percentage to prevent ghostty from hiding it
1859 // after a timeout.
1860 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1861 }
1862
1863 return v
1864}
1865
1866// ShortHelp implements [help.KeyMap].
1867func (m *UI) ShortHelp() []key.Binding {
1868 var binds []key.Binding
1869 k := &m.keyMap
1870 tab := k.Tab
1871 commands := k.Commands
1872 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
1873 commands.SetHelp("/ or ctrl+p", "commands")
1874 }
1875
1876 switch m.state {
1877 case uiInitialize:
1878 binds = append(binds, k.Quit)
1879 case uiChat:
1880 // Show cancel binding if agent is busy.
1881 if m.isAgentBusy() {
1882 cancelBinding := k.Chat.Cancel
1883 if m.isCanceling {
1884 cancelBinding.SetHelp("esc", "press again to cancel")
1885 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1886 cancelBinding.SetHelp("esc", "clear queue")
1887 }
1888 binds = append(binds, cancelBinding)
1889 }
1890
1891 if m.focus == uiFocusEditor {
1892 tab.SetHelp("tab", "focus chat")
1893 } else {
1894 tab.SetHelp("tab", "focus editor")
1895 }
1896
1897 binds = append(binds,
1898 tab,
1899 commands,
1900 k.Models,
1901 )
1902
1903 switch m.focus {
1904 case uiFocusEditor:
1905 binds = append(binds,
1906 k.Editor.Newline,
1907 )
1908 case uiFocusMain:
1909 binds = append(binds,
1910 k.Chat.UpDown,
1911 k.Chat.UpDownOneItem,
1912 k.Chat.PageUp,
1913 k.Chat.PageDown,
1914 k.Chat.Copy,
1915 )
1916 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1917 binds = append(binds, k.Chat.PillLeft)
1918 }
1919 }
1920 default:
1921 // TODO: other states
1922 // if m.session == nil {
1923 // no session selected
1924 binds = append(binds,
1925 commands,
1926 k.Models,
1927 k.Editor.Newline,
1928 )
1929 }
1930
1931 binds = append(binds,
1932 k.Quit,
1933 k.Help,
1934 )
1935
1936 return binds
1937}
1938
1939// FullHelp implements [help.KeyMap].
1940func (m *UI) FullHelp() [][]key.Binding {
1941 var binds [][]key.Binding
1942 k := &m.keyMap
1943 help := k.Help
1944 help.SetHelp("ctrl+g", "less")
1945 hasAttachments := len(m.attachments.List()) > 0
1946 hasSession := m.hasSession()
1947 commands := k.Commands
1948 if m.focus == uiFocusEditor && m.textarea.Value() == "" {
1949 commands.SetHelp("/ or ctrl+p", "commands")
1950 }
1951
1952 switch m.state {
1953 case uiInitialize:
1954 binds = append(binds,
1955 []key.Binding{
1956 k.Quit,
1957 })
1958 case uiChat:
1959 // Show cancel binding if agent is busy.
1960 if m.isAgentBusy() {
1961 cancelBinding := k.Chat.Cancel
1962 if m.isCanceling {
1963 cancelBinding.SetHelp("esc", "press again to cancel")
1964 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1965 cancelBinding.SetHelp("esc", "clear queue")
1966 }
1967 binds = append(binds, []key.Binding{cancelBinding})
1968 }
1969
1970 mainBinds := []key.Binding{}
1971 tab := k.Tab
1972 if m.focus == uiFocusEditor {
1973 tab.SetHelp("tab", "focus chat")
1974 } else {
1975 tab.SetHelp("tab", "focus editor")
1976 }
1977
1978 mainBinds = append(mainBinds,
1979 tab,
1980 commands,
1981 k.Models,
1982 k.Sessions,
1983 )
1984 if hasSession {
1985 mainBinds = append(mainBinds, k.Chat.NewSession)
1986 }
1987
1988 binds = append(binds, mainBinds)
1989
1990 switch m.focus {
1991 case uiFocusEditor:
1992 binds = append(binds,
1993 []key.Binding{
1994 k.Editor.Newline,
1995 k.Editor.AddImage,
1996 k.Editor.MentionFile,
1997 k.Editor.OpenEditor,
1998 },
1999 )
2000 if hasAttachments {
2001 binds = append(binds,
2002 []key.Binding{
2003 k.Editor.AttachmentDeleteMode,
2004 k.Editor.DeleteAllAttachments,
2005 k.Editor.Escape,
2006 },
2007 )
2008 }
2009 case uiFocusMain:
2010 binds = append(binds,
2011 []key.Binding{
2012 k.Chat.UpDown,
2013 k.Chat.UpDownOneItem,
2014 k.Chat.PageUp,
2015 k.Chat.PageDown,
2016 },
2017 []key.Binding{
2018 k.Chat.HalfPageUp,
2019 k.Chat.HalfPageDown,
2020 k.Chat.Home,
2021 k.Chat.End,
2022 },
2023 []key.Binding{
2024 k.Chat.Copy,
2025 k.Chat.ClearHighlight,
2026 },
2027 )
2028 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2029 binds = append(binds, []key.Binding{k.Chat.PillLeft})
2030 }
2031 }
2032 default:
2033 if m.session == nil {
2034 // no session selected
2035 binds = append(binds,
2036 []key.Binding{
2037 commands,
2038 k.Models,
2039 k.Sessions,
2040 },
2041 []key.Binding{
2042 k.Editor.Newline,
2043 k.Editor.AddImage,
2044 k.Editor.MentionFile,
2045 k.Editor.OpenEditor,
2046 },
2047 )
2048 if hasAttachments {
2049 binds = append(binds,
2050 []key.Binding{
2051 k.Editor.AttachmentDeleteMode,
2052 k.Editor.DeleteAllAttachments,
2053 k.Editor.Escape,
2054 },
2055 )
2056 }
2057 binds = append(binds,
2058 []key.Binding{
2059 help,
2060 },
2061 )
2062 }
2063 }
2064
2065 binds = append(binds,
2066 []key.Binding{
2067 help,
2068 k.Quit,
2069 },
2070 )
2071
2072 return binds
2073}
2074
2075// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2076func (m *UI) toggleCompactMode() tea.Cmd {
2077 m.forceCompactMode = !m.forceCompactMode
2078
2079 err := m.com.Config().SetCompactMode(m.forceCompactMode)
2080 if err != nil {
2081 return uiutil.ReportError(err)
2082 }
2083
2084 m.updateLayoutAndSize()
2085
2086 return nil
2087}
2088
2089// updateLayoutAndSize updates the layout and sizes of UI components.
2090func (m *UI) updateLayoutAndSize() {
2091 // Determine if we should be in compact mode
2092 if m.state == uiChat {
2093 if m.forceCompactMode {
2094 m.isCompact = true
2095 return
2096 }
2097 if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2098 m.isCompact = true
2099 } else {
2100 m.isCompact = false
2101 }
2102 }
2103
2104 m.layout = m.generateLayout(m.width, m.height)
2105 m.updateSize()
2106}
2107
2108// updateSize updates the sizes of UI components based on the current layout.
2109func (m *UI) updateSize() {
2110 // Set status width
2111 m.status.SetWidth(m.layout.status.Dx())
2112
2113 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2114 m.textarea.SetWidth(m.layout.editor.Dx())
2115 m.textarea.SetHeight(m.layout.editor.Dy())
2116 m.renderPills()
2117
2118 // Handle different app states
2119 switch m.state {
2120 case uiOnboarding, uiInitialize, uiLanding:
2121 m.renderHeader(false, m.layout.header.Dx())
2122
2123 case uiChat:
2124 if m.isCompact {
2125 m.renderHeader(true, m.layout.header.Dx())
2126 } else {
2127 m.renderSidebarLogo(m.layout.sidebar.Dx())
2128 }
2129 }
2130}
2131
2132// generateLayout calculates the layout rectangles for all UI components based
2133// on the current UI state and terminal dimensions.
2134func (m *UI) generateLayout(w, h int) layout {
2135 // The screen area we're working with
2136 area := image.Rect(0, 0, w, h)
2137
2138 // The help height
2139 helpHeight := 1
2140 // The editor height
2141 editorHeight := 5
2142 // The sidebar width
2143 sidebarWidth := 30
2144 // The header height
2145 const landingHeaderHeight = 4
2146
2147 var helpKeyMap help.KeyMap = m
2148 if m.status != nil && m.status.ShowingAll() {
2149 for _, row := range helpKeyMap.FullHelp() {
2150 helpHeight = max(helpHeight, len(row))
2151 }
2152 }
2153
2154 // Add app margins
2155 appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
2156 appRect.Min.Y += 1
2157 appRect.Max.Y -= 1
2158 helpRect.Min.Y -= 1
2159 appRect.Min.X += 1
2160 appRect.Max.X -= 1
2161
2162 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2163 // extra padding on left and right for these states
2164 appRect.Min.X += 1
2165 appRect.Max.X -= 1
2166 }
2167
2168 layout := layout{
2169 area: area,
2170 status: helpRect,
2171 }
2172
2173 // Handle different app states
2174 switch m.state {
2175 case uiOnboarding, uiInitialize:
2176 // Layout
2177 //
2178 // header
2179 // ------
2180 // main
2181 // ------
2182 // help
2183
2184 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2185 layout.header = headerRect
2186 layout.main = mainRect
2187
2188 case uiLanding:
2189 // Layout
2190 //
2191 // header
2192 // ------
2193 // main
2194 // ------
2195 // editor
2196 // ------
2197 // help
2198 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2199 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2200 // Remove extra padding from editor (but keep it for header and main)
2201 editorRect.Min.X -= 1
2202 editorRect.Max.X += 1
2203 layout.header = headerRect
2204 layout.main = mainRect
2205 layout.editor = editorRect
2206
2207 case uiChat:
2208 if m.isCompact {
2209 // Layout
2210 //
2211 // compact-header
2212 // ------
2213 // main
2214 // ------
2215 // editor
2216 // ------
2217 // help
2218 const compactHeaderHeight = 1
2219 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
2220 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2221 sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
2222 layout.sessionDetails = sessionDetailsArea
2223 layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2224 // Add one line gap between header and main content
2225 mainRect.Min.Y += 1
2226 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2227 mainRect.Max.X -= 1 // Add padding right
2228 layout.header = headerRect
2229 pillsHeight := m.pillsAreaHeight()
2230 if pillsHeight > 0 {
2231 pillsHeight = min(pillsHeight, mainRect.Dy())
2232 chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2233 layout.main = chatRect
2234 layout.pills = pillsRect
2235 } else {
2236 layout.main = mainRect
2237 }
2238 // Add bottom margin to main
2239 layout.main.Max.Y -= 1
2240 layout.editor = editorRect
2241 } else {
2242 // Layout
2243 //
2244 // ------|---
2245 // main |
2246 // ------| side
2247 // editor|
2248 // ----------
2249 // help
2250
2251 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
2252 // Add padding left
2253 sideRect.Min.X += 1
2254 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2255 mainRect.Max.X -= 1 // Add padding right
2256 layout.sidebar = sideRect
2257 pillsHeight := m.pillsAreaHeight()
2258 if pillsHeight > 0 {
2259 pillsHeight = min(pillsHeight, mainRect.Dy())
2260 chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2261 layout.main = chatRect
2262 layout.pills = pillsRect
2263 } else {
2264 layout.main = mainRect
2265 }
2266 // Add bottom margin to main
2267 layout.main.Max.Y -= 1
2268 layout.editor = editorRect
2269 }
2270 }
2271
2272 if !layout.editor.Empty() {
2273 // Add editor margins 1 top and bottom
2274 if len(m.attachments.List()) == 0 {
2275 layout.editor.Min.Y += 1
2276 }
2277 layout.editor.Max.Y -= 1
2278 }
2279
2280 return layout
2281}
2282
2283// layout defines the positioning of UI elements.
2284type layout struct {
2285 // area is the overall available area.
2286 area uv.Rectangle
2287
2288 // header is the header shown in special cases
2289 // e.x when the sidebar is collapsed
2290 // or when in the landing page
2291 // or in init/config
2292 header uv.Rectangle
2293
2294 // main is the area for the main pane. (e.x chat, configure, landing)
2295 main uv.Rectangle
2296
2297 // pills is the area for the pills panel.
2298 pills uv.Rectangle
2299
2300 // editor is the area for the editor pane.
2301 editor uv.Rectangle
2302
2303 // sidebar is the area for the sidebar.
2304 sidebar uv.Rectangle
2305
2306 // status is the area for the status view.
2307 status uv.Rectangle
2308
2309 // session details is the area for the session details overlay in compact mode.
2310 sessionDetails uv.Rectangle
2311}
2312
2313func (m *UI) openEditor(value string) tea.Cmd {
2314 tmpfile, err := os.CreateTemp("", "msg_*.md")
2315 if err != nil {
2316 return uiutil.ReportError(err)
2317 }
2318 defer tmpfile.Close() //nolint:errcheck
2319 if _, err := tmpfile.WriteString(value); err != nil {
2320 return uiutil.ReportError(err)
2321 }
2322 cmd, err := editor.Command(
2323 "crush",
2324 tmpfile.Name(),
2325 editor.AtPosition(
2326 m.textarea.Line()+1,
2327 m.textarea.Column()+1,
2328 ),
2329 )
2330 if err != nil {
2331 return uiutil.ReportError(err)
2332 }
2333 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2334 if err != nil {
2335 return uiutil.ReportError(err)
2336 }
2337 content, err := os.ReadFile(tmpfile.Name())
2338 if err != nil {
2339 return uiutil.ReportError(err)
2340 }
2341 if len(content) == 0 {
2342 return uiutil.ReportWarn("Message is empty")
2343 }
2344 os.Remove(tmpfile.Name())
2345 return openEditorMsg{
2346 Text: strings.TrimSpace(string(content)),
2347 }
2348 })
2349}
2350
2351// setEditorPrompt configures the textarea prompt function based on whether
2352// yolo mode is enabled.
2353func (m *UI) setEditorPrompt(yolo bool) {
2354 if yolo {
2355 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2356 return
2357 }
2358 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2359}
2360
2361// normalPromptFunc returns the normal editor prompt style (" > " on first
2362// line, "::: " on subsequent lines).
2363func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2364 t := m.com.Styles
2365 if info.LineNumber == 0 {
2366 if info.Focused {
2367 return " > "
2368 }
2369 return "::: "
2370 }
2371 if info.Focused {
2372 return t.EditorPromptNormalFocused.Render()
2373 }
2374 return t.EditorPromptNormalBlurred.Render()
2375}
2376
2377// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2378// and colored dots.
2379func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2380 t := m.com.Styles
2381 if info.LineNumber == 0 {
2382 if info.Focused {
2383 return t.EditorPromptYoloIconFocused.Render()
2384 } else {
2385 return t.EditorPromptYoloIconBlurred.Render()
2386 }
2387 }
2388 if info.Focused {
2389 return t.EditorPromptYoloDotsFocused.Render()
2390 }
2391 return t.EditorPromptYoloDotsBlurred.Render()
2392}
2393
2394// closeCompletions closes the completions popup and resets state.
2395func (m *UI) closeCompletions() {
2396 m.completionsOpen = false
2397 m.completionsQuery = ""
2398 m.completionsStartIndex = 0
2399 m.completions.Close()
2400}
2401
2402// insertFileCompletion inserts the selected file path into the textarea,
2403// replacing the @query, and adds the file as an attachment.
2404func (m *UI) insertFileCompletion(path string) tea.Cmd {
2405 value := m.textarea.Value()
2406 word := m.textareaWord()
2407
2408 // Find the @ and query to replace.
2409 if m.completionsStartIndex > len(value) {
2410 return nil
2411 }
2412
2413 // Build the new value: everything before @, the path, everything after query.
2414 endIdx := min(m.completionsStartIndex+len(word), len(value))
2415
2416 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2417 m.textarea.SetValue(newValue)
2418 m.textarea.MoveToEnd()
2419 m.textarea.InsertRune(' ')
2420
2421 return func() tea.Msg {
2422 absPath, _ := filepath.Abs(path)
2423
2424 if m.hasSession() {
2425 // Skip attachment if file was already read and hasn't been modified.
2426 lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2427 if !lastRead.IsZero() {
2428 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2429 return nil
2430 }
2431 }
2432 } else if slices.Contains(m.sessionFileReads, absPath) {
2433 return nil
2434 }
2435
2436 m.sessionFileReads = append(m.sessionFileReads, absPath)
2437
2438 // Add file as attachment.
2439 content, err := os.ReadFile(path)
2440 if err != nil {
2441 // If it fails, let the LLM handle it later.
2442 return nil
2443 }
2444
2445 return message.Attachment{
2446 FilePath: path,
2447 FileName: filepath.Base(path),
2448 MimeType: mimeOf(content),
2449 Content: content,
2450 }
2451 }
2452}
2453
2454// completionsPosition returns the X and Y position for the completions popup.
2455func (m *UI) completionsPosition() image.Point {
2456 cur := m.textarea.Cursor()
2457 if cur == nil {
2458 return image.Point{
2459 X: m.layout.editor.Min.X,
2460 Y: m.layout.editor.Min.Y,
2461 }
2462 }
2463 return image.Point{
2464 X: cur.X + m.layout.editor.Min.X,
2465 Y: m.layout.editor.Min.Y + cur.Y,
2466 }
2467}
2468
2469// textareaWord returns the current word at the cursor position.
2470func (m *UI) textareaWord() string {
2471 return m.textarea.Word()
2472}
2473
2474// isWhitespace returns true if the byte is a whitespace character.
2475func isWhitespace(b byte) bool {
2476 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2477}
2478
2479// isAgentBusy returns true if the agent coordinator exists and is currently
2480// busy processing a request.
2481func (m *UI) isAgentBusy() bool {
2482 return m.com.App != nil &&
2483 m.com.App.AgentCoordinator != nil &&
2484 m.com.App.AgentCoordinator.IsBusy()
2485}
2486
2487// hasSession returns true if there is an active session with a valid ID.
2488func (m *UI) hasSession() bool {
2489 return m.session != nil && m.session.ID != ""
2490}
2491
2492// mimeOf detects the MIME type of the given content.
2493func mimeOf(content []byte) string {
2494 mimeBufferSize := min(512, len(content))
2495 return http.DetectContentType(content[:mimeBufferSize])
2496}
2497
2498var readyPlaceholders = [...]string{
2499 "Ready!",
2500 "Ready...",
2501 "Ready?",
2502 "Ready for instructions",
2503}
2504
2505var workingPlaceholders = [...]string{
2506 "Working!",
2507 "Working...",
2508 "Brrrrr...",
2509 "Prrrrrrrr...",
2510 "Processing...",
2511 "Thinking...",
2512}
2513
2514// randomizePlaceholders selects random placeholder text for the textarea's
2515// ready and working states.
2516func (m *UI) randomizePlaceholders() {
2517 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2518 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2519}
2520
2521// renderEditorView renders the editor view with attachments if any.
2522func (m *UI) renderEditorView(width int) string {
2523 if len(m.attachments.List()) == 0 {
2524 return m.textarea.View()
2525 }
2526 return lipgloss.JoinVertical(
2527 lipgloss.Top,
2528 m.attachments.Render(width),
2529 m.textarea.View(),
2530 )
2531}
2532
2533// renderHeader renders and caches the header logo at the specified width.
2534func (m *UI) renderHeader(compact bool, width int) {
2535 if compact && m.session != nil && m.com.App != nil {
2536 m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2537 } else {
2538 m.header = renderLogo(m.com.Styles, compact, width)
2539 }
2540}
2541
2542// renderSidebarLogo renders and caches the sidebar logo at the specified
2543// width.
2544func (m *UI) renderSidebarLogo(width int) {
2545 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2546}
2547
2548// sendMessage sends a message with the given content and attachments.
2549func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2550 if m.com.App.AgentCoordinator == nil {
2551 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2552 }
2553
2554 var cmds []tea.Cmd
2555 if !m.hasSession() {
2556 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2557 if err != nil {
2558 return uiutil.ReportError(err)
2559 }
2560 if m.forceCompactMode {
2561 m.isCompact = true
2562 }
2563 if newSession.ID != "" {
2564 m.session = &newSession
2565 cmds = append(cmds, m.loadSession(newSession.ID))
2566 }
2567 m.setState(uiChat, m.focus)
2568 }
2569
2570 for _, path := range m.sessionFileReads {
2571 m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path)
2572 }
2573
2574 // Capture session ID to avoid race with main goroutine updating m.session.
2575 sessionID := m.session.ID
2576 cmds = append(cmds, func() tea.Msg {
2577 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2578 if err != nil {
2579 isCancelErr := errors.Is(err, context.Canceled)
2580 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2581 if isCancelErr || isPermissionErr {
2582 return nil
2583 }
2584 return uiutil.InfoMsg{
2585 Type: uiutil.InfoTypeError,
2586 Msg: err.Error(),
2587 }
2588 }
2589 return nil
2590 })
2591 return tea.Batch(cmds...)
2592}
2593
2594const cancelTimerDuration = 2 * time.Second
2595
2596// cancelTimerCmd creates a command that expires the cancel timer.
2597func cancelTimerCmd() tea.Cmd {
2598 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2599 return cancelTimerExpiredMsg{}
2600 })
2601}
2602
2603// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2604// and starts a timer. The second press (before the timer expires) actually
2605// cancels the agent.
2606func (m *UI) cancelAgent() tea.Cmd {
2607 if !m.hasSession() {
2608 return nil
2609 }
2610
2611 coordinator := m.com.App.AgentCoordinator
2612 if coordinator == nil {
2613 return nil
2614 }
2615
2616 if m.isCanceling {
2617 // Second escape press - actually cancel the agent.
2618 m.isCanceling = false
2619 coordinator.Cancel(m.session.ID)
2620 // Stop the spinning todo indicator.
2621 m.todoIsSpinning = false
2622 m.renderPills()
2623 return nil
2624 }
2625
2626 // Check if there are queued prompts - if so, clear the queue.
2627 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2628 coordinator.ClearQueue(m.session.ID)
2629 return nil
2630 }
2631
2632 // First escape press - set canceling state and start timer.
2633 m.isCanceling = true
2634 return cancelTimerCmd()
2635}
2636
2637// openDialog opens a dialog by its ID.
2638func (m *UI) openDialog(id string) tea.Cmd {
2639 var cmds []tea.Cmd
2640 switch id {
2641 case dialog.SessionsID:
2642 if cmd := m.openSessionsDialog(); cmd != nil {
2643 cmds = append(cmds, cmd)
2644 }
2645 case dialog.ModelsID:
2646 if cmd := m.openModelsDialog(); cmd != nil {
2647 cmds = append(cmds, cmd)
2648 }
2649 case dialog.CommandsID:
2650 if cmd := m.openCommandsDialog(); cmd != nil {
2651 cmds = append(cmds, cmd)
2652 }
2653 case dialog.ReasoningID:
2654 if cmd := m.openReasoningDialog(); cmd != nil {
2655 cmds = append(cmds, cmd)
2656 }
2657 case dialog.QuitID:
2658 if cmd := m.openQuitDialog(); cmd != nil {
2659 cmds = append(cmds, cmd)
2660 }
2661 default:
2662 // Unknown dialog
2663 break
2664 }
2665 return tea.Batch(cmds...)
2666}
2667
2668// openQuitDialog opens the quit confirmation dialog.
2669func (m *UI) openQuitDialog() tea.Cmd {
2670 if m.dialog.ContainsDialog(dialog.QuitID) {
2671 // Bring to front
2672 m.dialog.BringToFront(dialog.QuitID)
2673 return nil
2674 }
2675
2676 quitDialog := dialog.NewQuit(m.com)
2677 m.dialog.OpenDialog(quitDialog)
2678 return nil
2679}
2680
2681// openModelsDialog opens the models dialog.
2682func (m *UI) openModelsDialog() tea.Cmd {
2683 if m.dialog.ContainsDialog(dialog.ModelsID) {
2684 // Bring to front
2685 m.dialog.BringToFront(dialog.ModelsID)
2686 return nil
2687 }
2688
2689 isOnboarding := m.state == uiOnboarding
2690 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2691 if err != nil {
2692 return uiutil.ReportError(err)
2693 }
2694
2695 m.dialog.OpenDialog(modelsDialog)
2696
2697 return nil
2698}
2699
2700// openCommandsDialog opens the commands dialog.
2701func (m *UI) openCommandsDialog() tea.Cmd {
2702 if m.dialog.ContainsDialog(dialog.CommandsID) {
2703 // Bring to front
2704 m.dialog.BringToFront(dialog.CommandsID)
2705 return nil
2706 }
2707
2708 sessionID := ""
2709 if m.session != nil {
2710 sessionID = m.session.ID
2711 }
2712
2713 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2714 if err != nil {
2715 return uiutil.ReportError(err)
2716 }
2717
2718 m.dialog.OpenDialog(commands)
2719
2720 return nil
2721}
2722
2723// openReasoningDialog opens the reasoning effort dialog.
2724func (m *UI) openReasoningDialog() tea.Cmd {
2725 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2726 m.dialog.BringToFront(dialog.ReasoningID)
2727 return nil
2728 }
2729
2730 reasoningDialog, err := dialog.NewReasoning(m.com)
2731 if err != nil {
2732 return uiutil.ReportError(err)
2733 }
2734
2735 m.dialog.OpenDialog(reasoningDialog)
2736 return nil
2737}
2738
2739// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2740// it brings it to the front. Otherwise, it will list all the sessions and open
2741// the dialog.
2742func (m *UI) openSessionsDialog() tea.Cmd {
2743 if m.dialog.ContainsDialog(dialog.SessionsID) {
2744 // Bring to front
2745 m.dialog.BringToFront(dialog.SessionsID)
2746 return nil
2747 }
2748
2749 selectedSessionID := ""
2750 if m.session != nil {
2751 selectedSessionID = m.session.ID
2752 }
2753
2754 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2755 if err != nil {
2756 return uiutil.ReportError(err)
2757 }
2758
2759 m.dialog.OpenDialog(dialog)
2760 return nil
2761}
2762
2763// openFilesDialog opens the file picker dialog.
2764func (m *UI) openFilesDialog() tea.Cmd {
2765 if m.dialog.ContainsDialog(dialog.FilePickerID) {
2766 // Bring to front
2767 m.dialog.BringToFront(dialog.FilePickerID)
2768 return nil
2769 }
2770
2771 filePicker, cmd := dialog.NewFilePicker(m.com)
2772 filePicker.SetImageCapabilities(&m.caps)
2773 m.dialog.OpenDialog(filePicker)
2774
2775 return cmd
2776}
2777
2778// openPermissionsDialog opens the permissions dialog for a permission request.
2779func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2780 // Close any existing permissions dialog first.
2781 m.dialog.CloseDialog(dialog.PermissionsID)
2782
2783 // Get diff mode from config.
2784 var opts []dialog.PermissionsOption
2785 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2786 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2787 }
2788
2789 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2790 m.dialog.OpenDialog(permDialog)
2791 return nil
2792}
2793
2794// handlePermissionNotification updates tool items when permission state changes.
2795func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2796 toolItem := m.chat.MessageItem(notification.ToolCallID)
2797 if toolItem == nil {
2798 return
2799 }
2800
2801 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2802 if notification.Granted {
2803 permItem.SetStatus(chat.ToolStatusRunning)
2804 } else {
2805 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2806 }
2807 }
2808}
2809
2810// newSession clears the current session state and prepares for a new session.
2811// The actual session creation happens when the user sends their first message.
2812// Returns a command to reload prompt history.
2813func (m *UI) newSession() tea.Cmd {
2814 if !m.hasSession() {
2815 return nil
2816 }
2817
2818 m.session = nil
2819 m.sessionFiles = nil
2820 m.sessionFileReads = nil
2821 m.setState(uiLanding, uiFocusEditor)
2822 m.textarea.Focus()
2823 m.chat.Blur()
2824 m.chat.ClearMessages()
2825 m.pillsExpanded = false
2826 m.promptQueue = 0
2827 m.pillsView = ""
2828 m.historyReset()
2829 return m.loadPromptHistory()
2830}
2831
2832// handlePasteMsg handles a paste message.
2833func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2834 if m.dialog.HasDialogs() {
2835 return m.handleDialogMsg(msg)
2836 }
2837
2838 if m.focus != uiFocusEditor {
2839 return nil
2840 }
2841
2842 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
2843 return func() tea.Msg {
2844 content := []byte(msg.Content)
2845 if int64(len(content)) > common.MaxAttachmentSize {
2846 return uiutil.ReportWarn("Paste is too big (>5mb)")
2847 }
2848 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2849 mimeBufferSize := min(512, len(content))
2850 mimeType := http.DetectContentType(content[:mimeBufferSize])
2851 return message.Attachment{
2852 FileName: name,
2853 FilePath: name,
2854 MimeType: mimeType,
2855 Content: content,
2856 }
2857 }
2858 }
2859
2860 // Attempt to parse pasted content as file paths. If possible to parse,
2861 // all files exist and are valid, add as attachments.
2862 // Otherwise, paste as text.
2863 paths := fsext.PasteStringToPaths(msg.Content)
2864 allExistsAndValid := func() bool {
2865 for _, path := range paths {
2866 if _, err := os.Stat(path); os.IsNotExist(err) {
2867 return false
2868 }
2869
2870 lowerPath := strings.ToLower(path)
2871 isValid := false
2872 for _, ext := range common.AllowedImageTypes {
2873 if strings.HasSuffix(lowerPath, ext) {
2874 isValid = true
2875 break
2876 }
2877 }
2878 if !isValid {
2879 return false
2880 }
2881 }
2882 return true
2883 }
2884 if !allExistsAndValid() {
2885 var cmd tea.Cmd
2886 m.textarea, cmd = m.textarea.Update(msg)
2887 return cmd
2888 }
2889
2890 var cmds []tea.Cmd
2891 for _, path := range paths {
2892 cmds = append(cmds, m.handleFilePathPaste(path))
2893 }
2894 return tea.Batch(cmds...)
2895}
2896
2897// handleFilePathPaste handles a pasted file path.
2898func (m *UI) handleFilePathPaste(path string) tea.Cmd {
2899 return func() tea.Msg {
2900 fileInfo, err := os.Stat(path)
2901 if err != nil {
2902 return uiutil.ReportError(err)
2903 }
2904 if fileInfo.Size() > common.MaxAttachmentSize {
2905 return uiutil.ReportWarn("File is too big (>5mb)")
2906 }
2907
2908 content, err := os.ReadFile(path)
2909 if err != nil {
2910 return uiutil.ReportError(err)
2911 }
2912
2913 mimeBufferSize := min(512, len(content))
2914 mimeType := http.DetectContentType(content[:mimeBufferSize])
2915 fileName := filepath.Base(path)
2916 return message.Attachment{
2917 FilePath: path,
2918 FileName: fileName,
2919 MimeType: mimeType,
2920 Content: content,
2921 }
2922 }
2923}
2924
2925var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2926
2927func (m *UI) pasteIdx() int {
2928 result := 0
2929 for _, at := range m.attachments.List() {
2930 found := pasteRE.FindStringSubmatch(at.FileName)
2931 if len(found) == 0 {
2932 continue
2933 }
2934 idx, err := strconv.Atoi(found[1])
2935 if err == nil {
2936 result = max(result, idx)
2937 }
2938 }
2939 return result + 1
2940}
2941
2942// drawSessionDetails draws the session details in compact mode.
2943func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2944 if m.session == nil {
2945 return
2946 }
2947
2948 s := m.com.Styles
2949
2950 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2951 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2952
2953 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2954 blocks := []string{
2955 title,
2956 "",
2957 m.modelInfo(width),
2958 "",
2959 }
2960
2961 detailsHeader := lipgloss.JoinVertical(
2962 lipgloss.Left,
2963 blocks...,
2964 )
2965
2966 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2967
2968 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2969
2970 const maxSectionWidth = 50
2971 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2972 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2973
2974 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2975 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2976 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2977 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2978 uv.NewStyledString(
2979 s.CompactDetails.View.
2980 Width(area.Dx()).
2981 Render(
2982 lipgloss.JoinVertical(
2983 lipgloss.Left,
2984 detailsHeader,
2985 sections,
2986 version,
2987 ),
2988 ),
2989 ).Draw(scr, area)
2990}
2991
2992func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2993 load := func() tea.Msg {
2994 prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2995 if err != nil {
2996 // TODO: make this better
2997 return uiutil.ReportError(err)()
2998 }
2999
3000 if prompt == "" {
3001 return nil
3002 }
3003 return sendMessageMsg{
3004 Content: prompt,
3005 }
3006 }
3007
3008 var cmds []tea.Cmd
3009 if cmd := m.dialog.StartLoading(); cmd != nil {
3010 cmds = append(cmds, cmd)
3011 }
3012 cmds = append(cmds, load, func() tea.Msg {
3013 return closeDialogMsg{}
3014 })
3015
3016 return tea.Sequence(cmds...)
3017}
3018
3019func (m *UI) copyChatHighlight() tea.Cmd {
3020 text := m.chat.HighlightContent()
3021 return common.CopyToClipboardWithCallback(
3022 text,
3023 "Selected text copied to clipboard",
3024 func() tea.Msg {
3025 m.chat.ClearMouse()
3026 return nil
3027 },
3028 )
3029}
3030
3031// renderLogo renders the Crush logo with the given styles and dimensions.
3032func renderLogo(t *styles.Styles, compact bool, width int) string {
3033 return logo.Render(version.Version, compact, logo.Opts{
3034 FieldColor: t.LogoFieldColor,
3035 TitleColorA: t.LogoTitleColorA,
3036 TitleColorB: t.LogoTitleColorB,
3037 CharmColor: t.LogoCharmColor,
3038 VersionColor: t.LogoVersionColor,
3039 Width: width,
3040 })
3041}