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