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 focus: uiFocusNone,
262 state: uiOnboarding,
263 textarea: ta,
264 chat: ch,
265 completions: comp,
266 attachments: attachments,
267 todoSpinner: todoSpinner,
268 lspStates: make(map[string]app.LSPClientInfo),
269 mcpStates: make(map[string]mcp.ClientInfo),
270 }
271
272 status := NewStatus(com, ui)
273
274 // set onboarding state defaults
275 ui.onboarding.yesInitializeSelected = true
276
277 if !com.Config().IsConfigured() {
278 ui.state = uiOnboarding
279 } else if n, _ := config.ProjectNeedsInitialization(); n {
280 ui.state = uiInitialize
281 } else {
282 ui.state = uiLanding
283 ui.focus = uiFocusEditor
284 }
285
286 ui.setEditorPrompt(false)
287 ui.randomizePlaceholders()
288 ui.textarea.Placeholder = ui.readyPlaceholder
289 ui.status = status
290
291 // Initialize compact mode from config
292 ui.forceCompactMode = com.Config().Options.TUI.CompactMode
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// loadCustomCommands loads the custom commands asynchronously.
314func (m *UI) loadCustomCommands() tea.Cmd {
315 return func() tea.Msg {
316 customCommands, err := commands.LoadCustomCommands(m.com.Config())
317 if err != nil {
318 slog.Error("failed to load custom commands", "error", err)
319 }
320 return userCommandsLoadedMsg{Commands: customCommands}
321 }
322}
323
324// loadMCPrompts loads the MCP prompts asynchronously.
325func (m *UI) loadMCPrompts() tea.Cmd {
326 return func() tea.Msg {
327 prompts, err := commands.LoadMCPPrompts()
328 if err != nil {
329 slog.Error("failed to load mcp prompts", "error", err)
330 }
331 if prompts == nil {
332 // flag them as loaded even if there is none or an error
333 prompts = []commands.MCPPrompt{}
334 }
335 return mcpPromptsLoadedMsg{Prompts: prompts}
336 }
337}
338
339// Update handles updates to the UI model.
340func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
341 var cmds []tea.Cmd
342 if m.hasSession() && m.isAgentBusy() {
343 queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
344 if queueSize != m.promptQueue {
345 m.promptQueue = queueSize
346 m.updateLayoutAndSize()
347 }
348 }
349 switch msg := msg.(type) {
350 case tea.EnvMsg:
351 // Is this Windows Terminal?
352 if !m.sendProgressBar {
353 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
354 }
355 m.imgCaps.Env = uv.Environ(msg)
356 // Only query for image capabilities if the terminal is known to
357 // support Kitty graphics protocol. This prevents character bleeding
358 // on terminals that don't understand the APC escape sequences.
359 if m.QueryCapabilities {
360 cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
361 }
362 case loadSessionMsg:
363 m.state = uiChat
364 if m.forceCompactMode {
365 m.isCompact = true
366 }
367 m.session = msg.session
368 m.sessionFiles = msg.files
369 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
370 if err != nil {
371 cmds = append(cmds, uiutil.ReportError(err))
372 break
373 }
374 if cmd := m.setSessionMessages(msgs); cmd != nil {
375 cmds = append(cmds, cmd)
376 }
377 if hasInProgressTodo(m.session.Todos) {
378 // only start spinner if there is an in-progress todo
379 if m.isAgentBusy() {
380 m.todoIsSpinning = true
381 cmds = append(cmds, m.todoSpinner.Tick)
382 }
383 m.updateLayoutAndSize()
384 }
385
386 case sendMessageMsg:
387 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
388
389 case userCommandsLoadedMsg:
390 m.customCommands = msg.Commands
391 dia := m.dialog.Dialog(dialog.CommandsID)
392 if dia == nil {
393 break
394 }
395
396 commands, ok := dia.(*dialog.Commands)
397 if ok {
398 commands.SetCustomCommands(m.customCommands)
399 }
400 case mcpPromptsLoadedMsg:
401 m.mcpPrompts = msg.Prompts
402 dia := m.dialog.Dialog(dialog.CommandsID)
403 if dia == nil {
404 break
405 }
406
407 commands, ok := dia.(*dialog.Commands)
408 if ok {
409 commands.SetMCPPrompts(m.mcpPrompts)
410 }
411
412 case closeDialogMsg:
413 m.dialog.CloseFrontDialog()
414
415 case pubsub.Event[session.Session]:
416 if msg.Type == pubsub.DeletedEvent {
417 if m.session != nil && m.session.ID == msg.Payload.ID {
418 m.newSession()
419 }
420 break
421 }
422 if m.session != nil && msg.Payload.ID == m.session.ID {
423 prevHasInProgress := hasInProgressTodo(m.session.Todos)
424 m.session = &msg.Payload
425 if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
426 m.todoIsSpinning = true
427 cmds = append(cmds, m.todoSpinner.Tick)
428 m.updateLayoutAndSize()
429 }
430 }
431 case pubsub.Event[message.Message]:
432 // Check if this is a child session message for an agent tool.
433 if m.session == nil {
434 break
435 }
436 if msg.Payload.SessionID != m.session.ID {
437 // This might be a child session message from an agent tool.
438 if cmd := m.handleChildSessionMessage(msg); cmd != nil {
439 cmds = append(cmds, cmd)
440 }
441 break
442 }
443 switch msg.Type {
444 case pubsub.CreatedEvent:
445 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
446 case pubsub.UpdatedEvent:
447 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
448 case pubsub.DeletedEvent:
449 m.chat.RemoveMessage(msg.Payload.ID)
450 }
451 // start the spinner if there is a new message
452 if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
453 m.todoIsSpinning = true
454 cmds = append(cmds, m.todoSpinner.Tick)
455 }
456 // stop the spinner if the agent is not busy anymore
457 if m.todoIsSpinning && !m.isAgentBusy() {
458 m.todoIsSpinning = false
459 }
460 // there is a number of things that could change the pills here so we want to re-render
461 m.renderPills()
462 case pubsub.Event[history.File]:
463 cmds = append(cmds, m.handleFileEvent(msg.Payload))
464 case pubsub.Event[app.LSPEvent]:
465 m.lspStates = app.GetLSPStates()
466 case pubsub.Event[mcp.Event]:
467 m.mcpStates = mcp.GetStates()
468 // check if all mcps are initialized
469 initialized := true
470 for _, state := range m.mcpStates {
471 if state.State == mcp.StateStarting {
472 initialized = false
473 break
474 }
475 }
476 if initialized && m.mcpPrompts == nil {
477 cmds = append(cmds, m.loadMCPrompts())
478 }
479 case pubsub.Event[permission.PermissionRequest]:
480 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
481 cmds = append(cmds, cmd)
482 }
483 case pubsub.Event[permission.PermissionNotification]:
484 m.handlePermissionNotification(msg.Payload)
485 case cancelTimerExpiredMsg:
486 m.isCanceling = false
487 case tea.TerminalVersionMsg:
488 termVersion := strings.ToLower(msg.Name)
489 // Only enable progress bar for the following terminals.
490 if !m.sendProgressBar {
491 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
492 }
493 return m, nil
494 case tea.WindowSizeMsg:
495 m.width, m.height = msg.Width, msg.Height
496 m.handleCompactMode(m.width, m.height)
497 m.updateLayoutAndSize()
498 // XXX: We need to store cell dimensions for image rendering.
499 m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
500 case tea.KeyboardEnhancementsMsg:
501 m.keyenh = msg
502 if msg.SupportsKeyDisambiguation() {
503 m.keyMap.Models.SetHelp("ctrl+m", "models")
504 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
505 }
506 case copyChatHighlightMsg:
507 cmds = append(cmds, m.copyChatHighlight())
508 case tea.MouseClickMsg:
509 // Pass mouse events to dialogs first if any are open.
510 if m.dialog.HasDialogs() {
511 m.dialog.Update(msg)
512 return m, tea.Batch(cmds...)
513 }
514 switch m.state {
515 case uiChat:
516 x, y := msg.X, msg.Y
517 // Adjust for chat area position
518 x -= m.layout.main.Min.X
519 y -= m.layout.main.Min.Y
520 if m.chat.HandleMouseDown(x, y) {
521 m.lastClickTime = time.Now()
522 }
523 }
524
525 case tea.MouseMotionMsg:
526 // Pass mouse events to dialogs first if any are open.
527 if m.dialog.HasDialogs() {
528 m.dialog.Update(msg)
529 return m, tea.Batch(cmds...)
530 }
531
532 switch m.state {
533 case uiChat:
534 if msg.Y <= 0 {
535 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
536 cmds = append(cmds, cmd)
537 }
538 if !m.chat.SelectedItemInView() {
539 m.chat.SelectPrev()
540 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
541 cmds = append(cmds, cmd)
542 }
543 }
544 } else if msg.Y >= m.chat.Height()-1 {
545 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
546 cmds = append(cmds, cmd)
547 }
548 if !m.chat.SelectedItemInView() {
549 m.chat.SelectNext()
550 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
551 cmds = append(cmds, cmd)
552 }
553 }
554 }
555
556 x, y := msg.X, msg.Y
557 // Adjust for chat area position
558 x -= m.layout.main.Min.X
559 y -= m.layout.main.Min.Y
560 m.chat.HandleMouseDrag(x, y)
561 }
562
563 case tea.MouseReleaseMsg:
564 // Pass mouse events to dialogs first if any are open.
565 if m.dialog.HasDialogs() {
566 m.dialog.Update(msg)
567 return m, tea.Batch(cmds...)
568 }
569 const doubleClickThreshold = 500 * time.Millisecond
570
571 switch m.state {
572 case uiChat:
573 x, y := msg.X, msg.Y
574 // Adjust for chat area position
575 x -= m.layout.main.Min.X
576 y -= m.layout.main.Min.Y
577 if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
578 cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
579 if time.Since(m.lastClickTime) >= doubleClickThreshold {
580 return copyChatHighlightMsg{}
581 }
582 return nil
583 }))
584 }
585 }
586 case tea.MouseWheelMsg:
587 // Pass mouse events to dialogs first if any are open.
588 if m.dialog.HasDialogs() {
589 m.dialog.Update(msg)
590 return m, tea.Batch(cmds...)
591 }
592
593 // Otherwise handle mouse wheel for chat.
594 switch m.state {
595 case uiChat:
596 switch msg.Button {
597 case tea.MouseWheelUp:
598 if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
599 cmds = append(cmds, cmd)
600 }
601 if !m.chat.SelectedItemInView() {
602 m.chat.SelectPrev()
603 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
604 cmds = append(cmds, cmd)
605 }
606 }
607 case tea.MouseWheelDown:
608 if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
609 cmds = append(cmds, cmd)
610 }
611 if !m.chat.SelectedItemInView() {
612 m.chat.SelectNext()
613 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
614 cmds = append(cmds, cmd)
615 }
616 }
617 }
618 }
619 case anim.StepMsg:
620 if m.state == uiChat {
621 if cmd := m.chat.Animate(msg); cmd != nil {
622 cmds = append(cmds, cmd)
623 }
624 }
625 case spinner.TickMsg:
626 if m.dialog.HasDialogs() {
627 // route to dialog
628 if cmd := m.handleDialogMsg(msg); cmd != nil {
629 cmds = append(cmds, cmd)
630 }
631 }
632 if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
633 var cmd tea.Cmd
634 m.todoSpinner, cmd = m.todoSpinner.Update(msg)
635 if cmd != nil {
636 m.renderPills()
637 cmds = append(cmds, cmd)
638 }
639 }
640
641 case tea.KeyPressMsg:
642 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
643 cmds = append(cmds, cmd)
644 }
645 case tea.PasteMsg:
646 if cmd := m.handlePasteMsg(msg); cmd != nil {
647 cmds = append(cmds, cmd)
648 }
649 case openEditorMsg:
650 m.textarea.SetValue(msg.Text)
651 m.textarea.MoveToEnd()
652 case uiutil.InfoMsg:
653 m.status.SetInfoMsg(msg)
654 ttl := msg.TTL
655 if ttl <= 0 {
656 ttl = DefaultStatusTTL
657 }
658 cmds = append(cmds, clearInfoMsgCmd(ttl))
659 case uiutil.ClearStatusMsg:
660 m.status.ClearInfoMsg()
661 case completions.FilesLoadedMsg:
662 // Handle async file loading for completions.
663 if m.completionsOpen {
664 m.completions.SetFiles(msg.Files)
665 }
666 case uv.WindowPixelSizeEvent:
667 // [timage.RequestCapabilities] requests the terminal to send a window
668 // size event to help determine pixel dimensions.
669 m.imgCaps.PixelWidth = msg.Width
670 m.imgCaps.PixelHeight = msg.Height
671 case uv.KittyGraphicsEvent:
672 // [timage.RequestCapabilities] sends a Kitty graphics query and this
673 // captures the response. Any response means the terminal understands
674 // the protocol.
675 m.imgCaps.SupportsKittyGraphics = true
676 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
677 slog.Warn("unexpected Kitty graphics response",
678 "response", string(msg.Payload),
679 "options", msg.Options)
680 }
681 default:
682 if m.dialog.HasDialogs() {
683 if cmd := m.handleDialogMsg(msg); cmd != nil {
684 cmds = append(cmds, cmd)
685 }
686 }
687 }
688
689 // This logic gets triggered on any message type, but should it?
690 switch m.focus {
691 case uiFocusMain:
692 case uiFocusEditor:
693 // Textarea placeholder logic
694 if m.isAgentBusy() {
695 m.textarea.Placeholder = m.workingPlaceholder
696 } else {
697 m.textarea.Placeholder = m.readyPlaceholder
698 }
699 if m.com.App.Permissions.SkipRequests() {
700 m.textarea.Placeholder = "Yolo mode!"
701 }
702 }
703
704 // at this point this can only handle [message.Attachment] message, and we
705 // should return all cmds anyway.
706 _ = m.attachments.Update(msg)
707 return m, tea.Batch(cmds...)
708}
709
710// setSessionMessages sets the messages for the current session in the chat
711func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
712 var cmds []tea.Cmd
713 // Build tool result map to link tool calls with their results
714 msgPtrs := make([]*message.Message, len(msgs))
715 for i := range msgs {
716 msgPtrs[i] = &msgs[i]
717 }
718 toolResultMap := chat.BuildToolResultMap(msgPtrs)
719 if len(msgPtrs) > 0 {
720 m.lastUserMessageTime = msgPtrs[0].CreatedAt
721 }
722
723 // Add messages to chat with linked tool results
724 items := make([]chat.MessageItem, 0, len(msgs)*2)
725 for _, msg := range msgPtrs {
726 switch msg.Role {
727 case message.User:
728 m.lastUserMessageTime = msg.CreatedAt
729 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
730 case message.Assistant:
731 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
732 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
733 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
734 items = append(items, infoItem)
735 }
736 default:
737 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
738 }
739 }
740
741 // Load nested tool calls for agent/agentic_fetch tools.
742 m.loadNestedToolCalls(items)
743
744 // If the user switches between sessions while the agent is working we want
745 // to make sure the animations are shown.
746 for _, item := range items {
747 if animatable, ok := item.(chat.Animatable); ok {
748 if cmd := animatable.StartAnimation(); cmd != nil {
749 cmds = append(cmds, cmd)
750 }
751 }
752 }
753
754 m.chat.SetMessages(items...)
755 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
756 cmds = append(cmds, cmd)
757 }
758 m.chat.SelectLast()
759 return tea.Batch(cmds...)
760}
761
762// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
763func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
764 for _, item := range items {
765 nestedContainer, ok := item.(chat.NestedToolContainer)
766 if !ok {
767 continue
768 }
769 toolItem, ok := item.(chat.ToolMessageItem)
770 if !ok {
771 continue
772 }
773
774 tc := toolItem.ToolCall()
775 messageID := toolItem.MessageID()
776
777 // Get the agent tool session ID.
778 agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
779
780 // Fetch nested messages.
781 nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
782 if err != nil || len(nestedMsgs) == 0 {
783 continue
784 }
785
786 // Build tool result map for nested messages.
787 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
788 for i := range nestedMsgs {
789 nestedMsgPtrs[i] = &nestedMsgs[i]
790 }
791 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
792
793 // Extract nested tool items.
794 var nestedTools []chat.ToolMessageItem
795 for _, nestedMsg := range nestedMsgPtrs {
796 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
797 for _, nestedItem := range nestedItems {
798 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
799 // Mark nested tools as simple (compact) rendering.
800 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
801 simplifiable.SetCompact(true)
802 }
803 nestedTools = append(nestedTools, nestedToolItem)
804 }
805 }
806 }
807
808 // Recursively load nested tool calls for any agent tools within.
809 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
810 for i, nt := range nestedTools {
811 nestedMessageItems[i] = nt
812 }
813 m.loadNestedToolCalls(nestedMessageItems)
814
815 // Set nested tools on the parent.
816 nestedContainer.SetNestedTools(nestedTools)
817 }
818}
819
820// appendSessionMessage appends a new message to the current session in the chat
821// if the message is a tool result it will update the corresponding tool call message
822func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
823 var cmds []tea.Cmd
824 existing := m.chat.MessageItem(msg.ID)
825 if existing != nil {
826 // message already exists, skip
827 return nil
828 }
829 switch msg.Role {
830 case message.User:
831 m.lastUserMessageTime = msg.CreatedAt
832 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
833 for _, item := range items {
834 if animatable, ok := item.(chat.Animatable); ok {
835 if cmd := animatable.StartAnimation(); cmd != nil {
836 cmds = append(cmds, cmd)
837 }
838 }
839 }
840 m.chat.AppendMessages(items...)
841 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
842 cmds = append(cmds, cmd)
843 }
844 case message.Assistant:
845 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
846 for _, item := range items {
847 if animatable, ok := item.(chat.Animatable); ok {
848 if cmd := animatable.StartAnimation(); cmd != nil {
849 cmds = append(cmds, cmd)
850 }
851 }
852 }
853 m.chat.AppendMessages(items...)
854 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
855 cmds = append(cmds, cmd)
856 }
857 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
858 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
859 m.chat.AppendMessages(infoItem)
860 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
861 cmds = append(cmds, cmd)
862 }
863 }
864 case message.Tool:
865 for _, tr := range msg.ToolResults() {
866 toolItem := m.chat.MessageItem(tr.ToolCallID)
867 if toolItem == nil {
868 // we should have an item!
869 continue
870 }
871 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
872 toolMsgItem.SetResult(&tr)
873 }
874 }
875 }
876 return tea.Batch(cmds...)
877}
878
879// updateSessionMessage updates an existing message in the current session in the chat
880// when an assistant message is updated it may include updated tool calls as well
881// that is why we need to handle creating/updating each tool call message too
882func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
883 var cmds []tea.Cmd
884 existingItem := m.chat.MessageItem(msg.ID)
885 atBottom := m.chat.list.AtBottom()
886
887 if existingItem != nil {
888 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
889 assistantItem.SetMessage(&msg)
890 }
891 }
892
893 shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
894 // if the message of the assistant does not have any response just tool calls we need to remove it
895 if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
896 m.chat.RemoveMessage(msg.ID)
897 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
898 m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
899 }
900 }
901
902 if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
903 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
904 newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
905 m.chat.AppendMessages(newInfoItem)
906 }
907 }
908
909 var items []chat.MessageItem
910 for _, tc := range msg.ToolCalls() {
911 existingToolItem := m.chat.MessageItem(tc.ID)
912 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
913 existingToolCall := toolItem.ToolCall()
914 // only update if finished state changed or input changed
915 // to avoid clearing the cache
916 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
917 toolItem.SetToolCall(tc)
918 }
919 }
920 if existingToolItem == nil {
921 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
922 }
923 }
924
925 for _, item := range items {
926 if animatable, ok := item.(chat.Animatable); ok {
927 if cmd := animatable.StartAnimation(); cmd != nil {
928 cmds = append(cmds, cmd)
929 }
930 }
931 }
932
933 m.chat.AppendMessages(items...)
934 if atBottom {
935 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
936 cmds = append(cmds, cmd)
937 }
938 }
939
940 return tea.Batch(cmds...)
941}
942
943// handleChildSessionMessage handles messages from child sessions (agent tools).
944func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
945 var cmds []tea.Cmd
946
947 atBottom := m.chat.list.AtBottom()
948 // Only process messages with tool calls or results.
949 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
950 return nil
951 }
952
953 // Check if this is an agent tool session and parse it.
954 childSessionID := event.Payload.SessionID
955 _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
956 if !ok {
957 return nil
958 }
959
960 // Find the parent agent tool item.
961 var agentItem chat.NestedToolContainer
962 for i := 0; i < m.chat.Len(); i++ {
963 item := m.chat.MessageItem(toolCallID)
964 if item == nil {
965 continue
966 }
967 if agent, ok := item.(chat.NestedToolContainer); ok {
968 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
969 if toolMessageItem.ToolCall().ID == toolCallID {
970 // Verify this agent belongs to the correct parent message.
971 // We can't directly check parentMessageID on the item, so we trust the session parsing.
972 agentItem = agent
973 break
974 }
975 }
976 }
977 }
978
979 if agentItem == nil {
980 return nil
981 }
982
983 // Get existing nested tools.
984 nestedTools := agentItem.NestedTools()
985
986 // Update or create nested tool calls.
987 for _, tc := range event.Payload.ToolCalls() {
988 found := false
989 for _, existingTool := range nestedTools {
990 if existingTool.ToolCall().ID == tc.ID {
991 existingTool.SetToolCall(tc)
992 found = true
993 break
994 }
995 }
996 if !found {
997 // Create a new nested tool item.
998 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
999 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1000 simplifiable.SetCompact(true)
1001 }
1002 if animatable, ok := nestedItem.(chat.Animatable); ok {
1003 if cmd := animatable.StartAnimation(); cmd != nil {
1004 cmds = append(cmds, cmd)
1005 }
1006 }
1007 nestedTools = append(nestedTools, nestedItem)
1008 }
1009 }
1010
1011 // Update nested tool results.
1012 for _, tr := range event.Payload.ToolResults() {
1013 for _, nestedTool := range nestedTools {
1014 if nestedTool.ToolCall().ID == tr.ToolCallID {
1015 nestedTool.SetResult(&tr)
1016 break
1017 }
1018 }
1019 }
1020
1021 // Update the agent item with the new nested tools.
1022 agentItem.SetNestedTools(nestedTools)
1023
1024 // Update the chat so it updates the index map for animations to work as expected
1025 m.chat.UpdateNestedToolIDs(toolCallID)
1026
1027 if atBottom {
1028 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1029 cmds = append(cmds, cmd)
1030 }
1031 }
1032
1033 return tea.Batch(cmds...)
1034}
1035
1036func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1037 var cmds []tea.Cmd
1038 action := m.dialog.Update(msg)
1039 if action == nil {
1040 return tea.Batch(cmds...)
1041 }
1042
1043 isOnboarding := m.state == uiOnboarding
1044
1045 switch msg := action.(type) {
1046 // Generic dialog messages
1047 case dialog.ActionClose:
1048 if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1049 break
1050 }
1051
1052 m.dialog.CloseFrontDialog()
1053
1054 if isOnboarding {
1055 if cmd := m.openModelsDialog(); cmd != nil {
1056 cmds = append(cmds, cmd)
1057 }
1058 }
1059
1060 if m.focus == uiFocusEditor {
1061 cmds = append(cmds, m.textarea.Focus())
1062 }
1063 case dialog.ActionCmd:
1064 if msg.Cmd != nil {
1065 cmds = append(cmds, msg.Cmd)
1066 }
1067
1068 // Session dialog messages
1069 case dialog.ActionSelectSession:
1070 m.dialog.CloseDialog(dialog.SessionsID)
1071 cmds = append(cmds, m.loadSession(msg.Session.ID))
1072
1073 // Open dialog message
1074 case dialog.ActionOpenDialog:
1075 m.dialog.CloseDialog(dialog.CommandsID)
1076 if cmd := m.openDialog(msg.DialogID); cmd != nil {
1077 cmds = append(cmds, cmd)
1078 }
1079
1080 // Command dialog messages
1081 case dialog.ActionToggleYoloMode:
1082 yolo := !m.com.App.Permissions.SkipRequests()
1083 m.com.App.Permissions.SetSkipRequests(yolo)
1084 m.setEditorPrompt(yolo)
1085 m.dialog.CloseDialog(dialog.CommandsID)
1086 case dialog.ActionNewSession:
1087 if m.isAgentBusy() {
1088 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1089 break
1090 }
1091 m.newSession()
1092 m.dialog.CloseDialog(dialog.CommandsID)
1093 case dialog.ActionSummarize:
1094 if m.isAgentBusy() {
1095 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1096 break
1097 }
1098 cmds = append(cmds, func() tea.Msg {
1099 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1100 if err != nil {
1101 return uiutil.ReportError(err)()
1102 }
1103 return nil
1104 })
1105 m.dialog.CloseDialog(dialog.CommandsID)
1106 case dialog.ActionToggleHelp:
1107 m.status.ToggleHelp()
1108 m.dialog.CloseDialog(dialog.CommandsID)
1109 case dialog.ActionExternalEditor:
1110 if m.isAgentBusy() {
1111 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1112 break
1113 }
1114 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1115 m.dialog.CloseDialog(dialog.CommandsID)
1116 case dialog.ActionToggleCompactMode:
1117 cmds = append(cmds, m.toggleCompactMode())
1118 m.dialog.CloseDialog(dialog.CommandsID)
1119 case dialog.ActionToggleThinking:
1120 if m.isAgentBusy() {
1121 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1122 break
1123 }
1124
1125 cmds = append(cmds, func() tea.Msg {
1126 cfg := m.com.Config()
1127 if cfg == nil {
1128 return uiutil.ReportError(errors.New("configuration not found"))()
1129 }
1130
1131 agentCfg, ok := cfg.Agents[config.AgentCoder]
1132 if !ok {
1133 return uiutil.ReportError(errors.New("agent configuration not found"))()
1134 }
1135
1136 currentModel := cfg.Models[agentCfg.Model]
1137 currentModel.Think = !currentModel.Think
1138 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1139 return uiutil.ReportError(err)()
1140 }
1141 m.com.App.UpdateAgentModel(context.TODO())
1142 status := "disabled"
1143 if currentModel.Think {
1144 status = "enabled"
1145 }
1146 return uiutil.NewInfoMsg("Thinking mode " + status)
1147 })
1148 m.dialog.CloseDialog(dialog.CommandsID)
1149 case dialog.ActionQuit:
1150 cmds = append(cmds, tea.Quit)
1151 case dialog.ActionInitializeProject:
1152 if m.isAgentBusy() {
1153 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1154 break
1155 }
1156 cmds = append(cmds, m.initializeProject())
1157 m.dialog.CloseDialog(dialog.CommandsID)
1158
1159 case dialog.ActionSelectModel:
1160 if m.isAgentBusy() {
1161 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1162 break
1163 }
1164
1165 cfg := m.com.Config()
1166 if cfg == nil {
1167 cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1168 break
1169 }
1170
1171 var (
1172 providerID = msg.Model.Provider
1173 isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1174 isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1175 )
1176
1177 // Attempt to import GitHub Copilot tokens from VSCode if available.
1178 if isCopilot && !isConfigured() {
1179 config.Get().ImportCopilot()
1180 }
1181
1182 if !isConfigured() {
1183 m.dialog.CloseDialog(dialog.ModelsID)
1184 if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1185 cmds = append(cmds, cmd)
1186 }
1187 break
1188 }
1189
1190 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1191 cmds = append(cmds, uiutil.ReportError(err))
1192 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1193 // Ensure small model is set is unset.
1194 smallModel := m.com.App.GetDefaultSmallModel(providerID)
1195 if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
1196 cmds = append(cmds, uiutil.ReportError(err))
1197 }
1198 }
1199
1200 cmds = append(cmds, func() tea.Msg {
1201 if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1202 return uiutil.ReportError(err)
1203 }
1204
1205 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1206
1207 return uiutil.NewInfoMsg(modelMsg)
1208 })
1209
1210 m.dialog.CloseDialog(dialog.APIKeyInputID)
1211 m.dialog.CloseDialog(dialog.OAuthID)
1212 m.dialog.CloseDialog(dialog.ModelsID)
1213
1214 if isOnboarding {
1215 m.state = uiLanding
1216 m.focus = uiFocusEditor
1217
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.focus = 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.handleCompactMode(m.width, m.height)
2058 m.updateLayoutAndSize()
2059
2060 return nil
2061}
2062
2063// handleCompactMode updates the UI state based on window size and compact mode setting.
2064func (m *UI) handleCompactMode(newWidth, newHeight int) {
2065 if m.state == uiChat {
2066 if m.forceCompactMode {
2067 m.isCompact = true
2068 return
2069 }
2070 if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
2071 m.isCompact = true
2072 } else {
2073 m.isCompact = false
2074 }
2075 }
2076}
2077
2078// updateLayoutAndSize updates the layout and sizes of UI components.
2079func (m *UI) updateLayoutAndSize() {
2080 m.layout = m.generateLayout(m.width, m.height)
2081 m.updateSize()
2082}
2083
2084// updateSize updates the sizes of UI components based on the current layout.
2085func (m *UI) updateSize() {
2086 // Set status width
2087 m.status.SetWidth(m.layout.status.Dx())
2088
2089 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2090 m.textarea.SetWidth(m.layout.editor.Dx())
2091 m.textarea.SetHeight(m.layout.editor.Dy())
2092 m.renderPills()
2093
2094 // Handle different app states
2095 switch m.state {
2096 case uiOnboarding, uiInitialize, uiLanding:
2097 m.renderHeader(false, m.layout.header.Dx())
2098
2099 case uiChat:
2100 if m.isCompact {
2101 m.renderHeader(true, m.layout.header.Dx())
2102 } else {
2103 m.renderSidebarLogo(m.layout.sidebar.Dx())
2104 }
2105 }
2106}
2107
2108// generateLayout calculates the layout rectangles for all UI components based
2109// on the current UI state and terminal dimensions.
2110func (m *UI) generateLayout(w, h int) layout {
2111 // The screen area we're working with
2112 area := image.Rect(0, 0, w, h)
2113
2114 // The help height
2115 helpHeight := 1
2116 // The editor height
2117 editorHeight := 5
2118 // The sidebar width
2119 sidebarWidth := 30
2120 // The header height
2121 const landingHeaderHeight = 4
2122
2123 var helpKeyMap help.KeyMap = m
2124 if m.status.ShowingAll() {
2125 for _, row := range helpKeyMap.FullHelp() {
2126 helpHeight = max(helpHeight, len(row))
2127 }
2128 }
2129
2130 // Add app margins
2131 appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
2132 appRect.Min.Y += 1
2133 appRect.Max.Y -= 1
2134 helpRect.Min.Y -= 1
2135 appRect.Min.X += 1
2136 appRect.Max.X -= 1
2137
2138 if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2139 // extra padding on left and right for these states
2140 appRect.Min.X += 1
2141 appRect.Max.X -= 1
2142 }
2143
2144 layout := layout{
2145 area: area,
2146 status: helpRect,
2147 }
2148
2149 // Handle different app states
2150 switch m.state {
2151 case uiOnboarding, uiInitialize:
2152 // Layout
2153 //
2154 // header
2155 // ------
2156 // main
2157 // ------
2158 // help
2159
2160 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2161 layout.header = headerRect
2162 layout.main = mainRect
2163
2164 case uiLanding:
2165 // Layout
2166 //
2167 // header
2168 // ------
2169 // main
2170 // ------
2171 // editor
2172 // ------
2173 // help
2174 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2175 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2176 // Remove extra padding from editor (but keep it for header and main)
2177 editorRect.Min.X -= 1
2178 editorRect.Max.X += 1
2179 layout.header = headerRect
2180 layout.main = mainRect
2181 layout.editor = editorRect
2182
2183 case uiChat:
2184 if m.isCompact {
2185 // Layout
2186 //
2187 // compact-header
2188 // ------
2189 // main
2190 // ------
2191 // editor
2192 // ------
2193 // help
2194 const compactHeaderHeight = 1
2195 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
2196 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2197 sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
2198 layout.sessionDetails = sessionDetailsArea
2199 layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2200 // Add one line gap between header and main content
2201 mainRect.Min.Y += 1
2202 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2203 mainRect.Max.X -= 1 // Add padding right
2204 layout.header = headerRect
2205 pillsHeight := m.pillsAreaHeight()
2206 if pillsHeight > 0 {
2207 pillsHeight = min(pillsHeight, mainRect.Dy())
2208 chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2209 layout.main = chatRect
2210 layout.pills = pillsRect
2211 } else {
2212 layout.main = mainRect
2213 }
2214 // Add bottom margin to main
2215 layout.main.Max.Y -= 1
2216 layout.editor = editorRect
2217 } else {
2218 // Layout
2219 //
2220 // ------|---
2221 // main |
2222 // ------| side
2223 // editor|
2224 // ----------
2225 // help
2226
2227 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
2228 // Add padding left
2229 sideRect.Min.X += 1
2230 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2231 mainRect.Max.X -= 1 // Add padding right
2232 layout.sidebar = sideRect
2233 pillsHeight := m.pillsAreaHeight()
2234 if pillsHeight > 0 {
2235 pillsHeight = min(pillsHeight, mainRect.Dy())
2236 chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2237 layout.main = chatRect
2238 layout.pills = pillsRect
2239 } else {
2240 layout.main = mainRect
2241 }
2242 // Add bottom margin to main
2243 layout.main.Max.Y -= 1
2244 layout.editor = editorRect
2245 }
2246 }
2247
2248 if !layout.editor.Empty() {
2249 // Add editor margins 1 top and bottom
2250 layout.editor.Min.Y += 1
2251 layout.editor.Max.Y -= 1
2252 }
2253
2254 return layout
2255}
2256
2257// layout defines the positioning of UI elements.
2258type layout struct {
2259 // area is the overall available area.
2260 area uv.Rectangle
2261
2262 // header is the header shown in special cases
2263 // e.x when the sidebar is collapsed
2264 // or when in the landing page
2265 // or in init/config
2266 header uv.Rectangle
2267
2268 // main is the area for the main pane. (e.x chat, configure, landing)
2269 main uv.Rectangle
2270
2271 // pills is the area for the pills panel.
2272 pills uv.Rectangle
2273
2274 // editor is the area for the editor pane.
2275 editor uv.Rectangle
2276
2277 // sidebar is the area for the sidebar.
2278 sidebar uv.Rectangle
2279
2280 // status is the area for the status view.
2281 status uv.Rectangle
2282
2283 // session details is the area for the session details overlay in compact mode.
2284 sessionDetails uv.Rectangle
2285}
2286
2287func (m *UI) openEditor(value string) tea.Cmd {
2288 tmpfile, err := os.CreateTemp("", "msg_*.md")
2289 if err != nil {
2290 return uiutil.ReportError(err)
2291 }
2292 defer tmpfile.Close() //nolint:errcheck
2293 if _, err := tmpfile.WriteString(value); err != nil {
2294 return uiutil.ReportError(err)
2295 }
2296 cmd, err := editor.Command(
2297 "crush",
2298 tmpfile.Name(),
2299 editor.AtPosition(
2300 m.textarea.Line()+1,
2301 m.textarea.Column()+1,
2302 ),
2303 )
2304 if err != nil {
2305 return uiutil.ReportError(err)
2306 }
2307 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2308 if err != nil {
2309 return uiutil.ReportError(err)
2310 }
2311 content, err := os.ReadFile(tmpfile.Name())
2312 if err != nil {
2313 return uiutil.ReportError(err)
2314 }
2315 if len(content) == 0 {
2316 return uiutil.ReportWarn("Message is empty")
2317 }
2318 os.Remove(tmpfile.Name())
2319 return openEditorMsg{
2320 Text: strings.TrimSpace(string(content)),
2321 }
2322 })
2323}
2324
2325// setEditorPrompt configures the textarea prompt function based on whether
2326// yolo mode is enabled.
2327func (m *UI) setEditorPrompt(yolo bool) {
2328 if yolo {
2329 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2330 return
2331 }
2332 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2333}
2334
2335// normalPromptFunc returns the normal editor prompt style (" > " on first
2336// line, "::: " on subsequent lines).
2337func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2338 t := m.com.Styles
2339 if info.LineNumber == 0 {
2340 if info.Focused {
2341 return " > "
2342 }
2343 return "::: "
2344 }
2345 if info.Focused {
2346 return t.EditorPromptNormalFocused.Render()
2347 }
2348 return t.EditorPromptNormalBlurred.Render()
2349}
2350
2351// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2352// and colored dots.
2353func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2354 t := m.com.Styles
2355 if info.LineNumber == 0 {
2356 if info.Focused {
2357 return t.EditorPromptYoloIconFocused.Render()
2358 } else {
2359 return t.EditorPromptYoloIconBlurred.Render()
2360 }
2361 }
2362 if info.Focused {
2363 return t.EditorPromptYoloDotsFocused.Render()
2364 }
2365 return t.EditorPromptYoloDotsBlurred.Render()
2366}
2367
2368// closeCompletions closes the completions popup and resets state.
2369func (m *UI) closeCompletions() {
2370 m.completionsOpen = false
2371 m.completionsQuery = ""
2372 m.completionsStartIndex = 0
2373 m.completions.Close()
2374}
2375
2376// insertFileCompletion inserts the selected file path into the textarea,
2377// replacing the @query, and adds the file as an attachment.
2378func (m *UI) insertFileCompletion(path string) tea.Cmd {
2379 value := m.textarea.Value()
2380 word := m.textareaWord()
2381
2382 // Find the @ and query to replace.
2383 if m.completionsStartIndex > len(value) {
2384 return nil
2385 }
2386
2387 // Build the new value: everything before @, the path, everything after query.
2388 endIdx := min(m.completionsStartIndex+len(word), len(value))
2389
2390 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2391 m.textarea.SetValue(newValue)
2392 m.textarea.MoveToEnd()
2393 m.textarea.InsertRune(' ')
2394
2395 return func() tea.Msg {
2396 absPath, _ := filepath.Abs(path)
2397 // Skip attachment if file was already read and hasn't been modified.
2398 lastRead := filetracker.LastReadTime(absPath)
2399 if !lastRead.IsZero() {
2400 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2401 return nil
2402 }
2403 }
2404
2405 // Add file as attachment.
2406 content, err := os.ReadFile(path)
2407 if err != nil {
2408 // If it fails, let the LLM handle it later.
2409 return nil
2410 }
2411 filetracker.RecordRead(absPath)
2412
2413 return message.Attachment{
2414 FilePath: path,
2415 FileName: filepath.Base(path),
2416 MimeType: mimeOf(content),
2417 Content: content,
2418 }
2419 }
2420}
2421
2422// completionsPosition returns the X and Y position for the completions popup.
2423func (m *UI) completionsPosition() image.Point {
2424 cur := m.textarea.Cursor()
2425 if cur == nil {
2426 return image.Point{
2427 X: m.layout.editor.Min.X,
2428 Y: m.layout.editor.Min.Y,
2429 }
2430 }
2431 return image.Point{
2432 X: cur.X + m.layout.editor.Min.X,
2433 Y: m.layout.editor.Min.Y + cur.Y,
2434 }
2435}
2436
2437// textareaWord returns the current word at the cursor position.
2438func (m *UI) textareaWord() string {
2439 return m.textarea.Word()
2440}
2441
2442// isWhitespace returns true if the byte is a whitespace character.
2443func isWhitespace(b byte) bool {
2444 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2445}
2446
2447// isAgentBusy returns true if the agent coordinator exists and is currently
2448// busy processing a request.
2449func (m *UI) isAgentBusy() bool {
2450 return m.com.App != nil &&
2451 m.com.App.AgentCoordinator != nil &&
2452 m.com.App.AgentCoordinator.IsBusy()
2453}
2454
2455// hasSession returns true if there is an active session with a valid ID.
2456func (m *UI) hasSession() bool {
2457 return m.session != nil && m.session.ID != ""
2458}
2459
2460// mimeOf detects the MIME type of the given content.
2461func mimeOf(content []byte) string {
2462 mimeBufferSize := min(512, len(content))
2463 return http.DetectContentType(content[:mimeBufferSize])
2464}
2465
2466var readyPlaceholders = [...]string{
2467 "Ready!",
2468 "Ready...",
2469 "Ready?",
2470 "Ready for instructions",
2471}
2472
2473var workingPlaceholders = [...]string{
2474 "Working!",
2475 "Working...",
2476 "Brrrrr...",
2477 "Prrrrrrrr...",
2478 "Processing...",
2479 "Thinking...",
2480}
2481
2482// randomizePlaceholders selects random placeholder text for the textarea's
2483// ready and working states.
2484func (m *UI) randomizePlaceholders() {
2485 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2486 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2487}
2488
2489// renderEditorView renders the editor view with attachments if any.
2490func (m *UI) renderEditorView(width int) string {
2491 if len(m.attachments.List()) == 0 {
2492 return m.textarea.View()
2493 }
2494 return lipgloss.JoinVertical(
2495 lipgloss.Top,
2496 m.attachments.Render(width),
2497 m.textarea.View(),
2498 )
2499}
2500
2501// renderHeader renders and caches the header logo at the specified width.
2502func (m *UI) renderHeader(compact bool, width int) {
2503 if compact && m.session != nil && m.com.App != nil {
2504 m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2505 } else {
2506 m.header = renderLogo(m.com.Styles, compact, width)
2507 }
2508}
2509
2510// renderSidebarLogo renders and caches the sidebar logo at the specified
2511// width.
2512func (m *UI) renderSidebarLogo(width int) {
2513 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2514}
2515
2516// sendMessage sends a message with the given content and attachments.
2517func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2518 if m.com.App.AgentCoordinator == nil {
2519 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2520 }
2521
2522 var cmds []tea.Cmd
2523 if !m.hasSession() {
2524 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2525 if err != nil {
2526 return uiutil.ReportError(err)
2527 }
2528 m.state = uiChat
2529 if m.forceCompactMode {
2530 m.isCompact = true
2531 }
2532 if newSession.ID != "" {
2533 m.session = &newSession
2534 cmds = append(cmds, m.loadSession(newSession.ID))
2535 }
2536 }
2537
2538 // Capture session ID to avoid race with main goroutine updating m.session.
2539 sessionID := m.session.ID
2540 cmds = append(cmds, func() tea.Msg {
2541 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2542 if err != nil {
2543 isCancelErr := errors.Is(err, context.Canceled)
2544 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2545 if isCancelErr || isPermissionErr {
2546 return nil
2547 }
2548 return uiutil.InfoMsg{
2549 Type: uiutil.InfoTypeError,
2550 Msg: err.Error(),
2551 }
2552 }
2553 return nil
2554 })
2555 return tea.Batch(cmds...)
2556}
2557
2558const cancelTimerDuration = 2 * time.Second
2559
2560// cancelTimerCmd creates a command that expires the cancel timer.
2561func cancelTimerCmd() tea.Cmd {
2562 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2563 return cancelTimerExpiredMsg{}
2564 })
2565}
2566
2567// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2568// and starts a timer. The second press (before the timer expires) actually
2569// cancels the agent.
2570func (m *UI) cancelAgent() tea.Cmd {
2571 if !m.hasSession() {
2572 return nil
2573 }
2574
2575 coordinator := m.com.App.AgentCoordinator
2576 if coordinator == nil {
2577 return nil
2578 }
2579
2580 if m.isCanceling {
2581 // Second escape press - actually cancel the agent.
2582 m.isCanceling = false
2583 coordinator.Cancel(m.session.ID)
2584 // Stop the spinning todo indicator.
2585 m.todoIsSpinning = false
2586 m.renderPills()
2587 return nil
2588 }
2589
2590 // Check if there are queued prompts - if so, clear the queue.
2591 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2592 coordinator.ClearQueue(m.session.ID)
2593 return nil
2594 }
2595
2596 // First escape press - set canceling state and start timer.
2597 m.isCanceling = true
2598 return cancelTimerCmd()
2599}
2600
2601// openDialog opens a dialog by its ID.
2602func (m *UI) openDialog(id string) tea.Cmd {
2603 var cmds []tea.Cmd
2604 switch id {
2605 case dialog.SessionsID:
2606 if cmd := m.openSessionsDialog(); cmd != nil {
2607 cmds = append(cmds, cmd)
2608 }
2609 case dialog.ModelsID:
2610 if cmd := m.openModelsDialog(); cmd != nil {
2611 cmds = append(cmds, cmd)
2612 }
2613 case dialog.CommandsID:
2614 if cmd := m.openCommandsDialog(); cmd != nil {
2615 cmds = append(cmds, cmd)
2616 }
2617 case dialog.ReasoningID:
2618 if cmd := m.openReasoningDialog(); cmd != nil {
2619 cmds = append(cmds, cmd)
2620 }
2621 case dialog.QuitID:
2622 if cmd := m.openQuitDialog(); cmd != nil {
2623 cmds = append(cmds, cmd)
2624 }
2625 default:
2626 // Unknown dialog
2627 break
2628 }
2629 return tea.Batch(cmds...)
2630}
2631
2632// openQuitDialog opens the quit confirmation dialog.
2633func (m *UI) openQuitDialog() tea.Cmd {
2634 if m.dialog.ContainsDialog(dialog.QuitID) {
2635 // Bring to front
2636 m.dialog.BringToFront(dialog.QuitID)
2637 return nil
2638 }
2639
2640 quitDialog := dialog.NewQuit(m.com)
2641 m.dialog.OpenDialog(quitDialog)
2642 return nil
2643}
2644
2645// openModelsDialog opens the models dialog.
2646func (m *UI) openModelsDialog() tea.Cmd {
2647 if m.dialog.ContainsDialog(dialog.ModelsID) {
2648 // Bring to front
2649 m.dialog.BringToFront(dialog.ModelsID)
2650 return nil
2651 }
2652
2653 isOnboarding := m.state == uiOnboarding
2654 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2655 if err != nil {
2656 return uiutil.ReportError(err)
2657 }
2658
2659 m.dialog.OpenDialog(modelsDialog)
2660
2661 return nil
2662}
2663
2664// openCommandsDialog opens the commands dialog.
2665func (m *UI) openCommandsDialog() tea.Cmd {
2666 if m.dialog.ContainsDialog(dialog.CommandsID) {
2667 // Bring to front
2668 m.dialog.BringToFront(dialog.CommandsID)
2669 return nil
2670 }
2671
2672 sessionID := ""
2673 if m.session != nil {
2674 sessionID = m.session.ID
2675 }
2676
2677 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2678 if err != nil {
2679 return uiutil.ReportError(err)
2680 }
2681
2682 m.dialog.OpenDialog(commands)
2683
2684 return nil
2685}
2686
2687// openReasoningDialog opens the reasoning effort dialog.
2688func (m *UI) openReasoningDialog() tea.Cmd {
2689 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2690 m.dialog.BringToFront(dialog.ReasoningID)
2691 return nil
2692 }
2693
2694 reasoningDialog, err := dialog.NewReasoning(m.com)
2695 if err != nil {
2696 return uiutil.ReportError(err)
2697 }
2698
2699 m.dialog.OpenDialog(reasoningDialog)
2700 return nil
2701}
2702
2703// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2704// it brings it to the front. Otherwise, it will list all the sessions and open
2705// the dialog.
2706func (m *UI) openSessionsDialog() tea.Cmd {
2707 if m.dialog.ContainsDialog(dialog.SessionsID) {
2708 // Bring to front
2709 m.dialog.BringToFront(dialog.SessionsID)
2710 return nil
2711 }
2712
2713 selectedSessionID := ""
2714 if m.session != nil {
2715 selectedSessionID = m.session.ID
2716 }
2717
2718 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2719 if err != nil {
2720 return uiutil.ReportError(err)
2721 }
2722
2723 m.dialog.OpenDialog(dialog)
2724 return nil
2725}
2726
2727// openFilesDialog opens the file picker dialog.
2728func (m *UI) openFilesDialog() tea.Cmd {
2729 if m.dialog.ContainsDialog(dialog.FilePickerID) {
2730 // Bring to front
2731 m.dialog.BringToFront(dialog.FilePickerID)
2732 return nil
2733 }
2734
2735 filePicker, cmd := dialog.NewFilePicker(m.com)
2736 filePicker.SetImageCapabilities(&m.imgCaps)
2737 m.dialog.OpenDialog(filePicker)
2738
2739 return cmd
2740}
2741
2742// openPermissionsDialog opens the permissions dialog for a permission request.
2743func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2744 // Close any existing permissions dialog first.
2745 m.dialog.CloseDialog(dialog.PermissionsID)
2746
2747 // Get diff mode from config.
2748 var opts []dialog.PermissionsOption
2749 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2750 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2751 }
2752
2753 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2754 m.dialog.OpenDialog(permDialog)
2755 return nil
2756}
2757
2758// handlePermissionNotification updates tool items when permission state changes.
2759func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2760 toolItem := m.chat.MessageItem(notification.ToolCallID)
2761 if toolItem == nil {
2762 return
2763 }
2764
2765 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2766 if notification.Granted {
2767 permItem.SetStatus(chat.ToolStatusRunning)
2768 } else {
2769 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2770 }
2771 }
2772}
2773
2774// newSession clears the current session state and prepares for a new session.
2775// The actual session creation happens when the user sends their first message.
2776func (m *UI) newSession() {
2777 if !m.hasSession() {
2778 return
2779 }
2780
2781 m.session = nil
2782 m.sessionFiles = nil
2783 m.state = uiLanding
2784 m.focus = uiFocusEditor
2785 m.textarea.Focus()
2786 m.chat.Blur()
2787 m.chat.ClearMessages()
2788 m.pillsExpanded = false
2789 m.promptQueue = 0
2790 m.pillsView = ""
2791}
2792
2793// handlePasteMsg handles a paste message.
2794func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2795 if m.dialog.HasDialogs() {
2796 return m.handleDialogMsg(msg)
2797 }
2798
2799 if m.focus != uiFocusEditor {
2800 return nil
2801 }
2802
2803 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
2804 return func() tea.Msg {
2805 content := []byte(msg.Content)
2806 if int64(len(content)) > common.MaxAttachmentSize {
2807 return uiutil.ReportWarn("Paste is too big (>5mb)")
2808 }
2809 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2810 mimeBufferSize := min(512, len(content))
2811 mimeType := http.DetectContentType(content[:mimeBufferSize])
2812 return message.Attachment{
2813 FileName: name,
2814 FilePath: name,
2815 MimeType: mimeType,
2816 Content: content,
2817 }
2818 }
2819 }
2820
2821 // Attempt to parse pasted content as file paths. If possible to parse,
2822 // all files exist and are valid, add as attachments.
2823 // Otherwise, paste as text.
2824 paths := fsext.PasteStringToPaths(msg.Content)
2825 allExistsAndValid := func() bool {
2826 for _, path := range paths {
2827 if _, err := os.Stat(path); os.IsNotExist(err) {
2828 return false
2829 }
2830
2831 lowerPath := strings.ToLower(path)
2832 isValid := false
2833 for _, ext := range common.AllowedImageTypes {
2834 if strings.HasSuffix(lowerPath, ext) {
2835 isValid = true
2836 break
2837 }
2838 }
2839 if !isValid {
2840 return false
2841 }
2842 }
2843 return true
2844 }
2845 if !allExistsAndValid() {
2846 var cmd tea.Cmd
2847 m.textarea, cmd = m.textarea.Update(msg)
2848 return cmd
2849 }
2850
2851 var cmds []tea.Cmd
2852 for _, path := range paths {
2853 cmds = append(cmds, m.handleFilePathPaste(path))
2854 }
2855 return tea.Batch(cmds...)
2856}
2857
2858// handleFilePathPaste handles a pasted file path.
2859func (m *UI) handleFilePathPaste(path string) tea.Cmd {
2860 return func() tea.Msg {
2861 fileInfo, err := os.Stat(path)
2862 if err != nil {
2863 return uiutil.ReportError(err)
2864 }
2865 if fileInfo.Size() > common.MaxAttachmentSize {
2866 return uiutil.ReportWarn("File is too big (>5mb)")
2867 }
2868
2869 content, err := os.ReadFile(path)
2870 if err != nil {
2871 return uiutil.ReportError(err)
2872 }
2873
2874 mimeBufferSize := min(512, len(content))
2875 mimeType := http.DetectContentType(content[:mimeBufferSize])
2876 fileName := filepath.Base(path)
2877 return message.Attachment{
2878 FilePath: path,
2879 FileName: fileName,
2880 MimeType: mimeType,
2881 Content: content,
2882 }
2883 }
2884}
2885
2886var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2887
2888func (m *UI) pasteIdx() int {
2889 result := 0
2890 for _, at := range m.attachments.List() {
2891 found := pasteRE.FindStringSubmatch(at.FileName)
2892 if len(found) == 0 {
2893 continue
2894 }
2895 idx, err := strconv.Atoi(found[1])
2896 if err == nil {
2897 result = max(result, idx)
2898 }
2899 }
2900 return result + 1
2901}
2902
2903// drawSessionDetails draws the session details in compact mode.
2904func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2905 if m.session == nil {
2906 return
2907 }
2908
2909 s := m.com.Styles
2910
2911 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2912 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2913
2914 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2915 blocks := []string{
2916 title,
2917 "",
2918 m.modelInfo(width),
2919 "",
2920 }
2921
2922 detailsHeader := lipgloss.JoinVertical(
2923 lipgloss.Left,
2924 blocks...,
2925 )
2926
2927 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2928
2929 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2930
2931 const maxSectionWidth = 50
2932 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2933 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2934
2935 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2936 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2937 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2938 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2939 uv.NewStyledString(
2940 s.CompactDetails.View.
2941 Width(area.Dx()).
2942 Render(
2943 lipgloss.JoinVertical(
2944 lipgloss.Left,
2945 detailsHeader,
2946 sections,
2947 version,
2948 ),
2949 ),
2950 ).Draw(scr, area)
2951}
2952
2953func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2954 load := func() tea.Msg {
2955 prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2956 if err != nil {
2957 // TODO: make this better
2958 return uiutil.ReportError(err)()
2959 }
2960
2961 if prompt == "" {
2962 return nil
2963 }
2964 return sendMessageMsg{
2965 Content: prompt,
2966 }
2967 }
2968
2969 var cmds []tea.Cmd
2970 if cmd := m.dialog.StartLoading(); cmd != nil {
2971 cmds = append(cmds, cmd)
2972 }
2973 cmds = append(cmds, load, func() tea.Msg {
2974 return closeDialogMsg{}
2975 })
2976
2977 return tea.Sequence(cmds...)
2978}
2979
2980func (m *UI) copyChatHighlight() tea.Cmd {
2981 text := m.chat.HighlightContent()
2982 return common.CopyToClipboardWithCallback(
2983 text,
2984 "Selected text copied to clipboard",
2985 func() tea.Msg {
2986 m.chat.ClearMouse()
2987 return nil
2988 },
2989 )
2990}
2991
2992// renderLogo renders the Crush logo with the given styles and dimensions.
2993func renderLogo(t *styles.Styles, compact bool, width int) string {
2994 return logo.Render(version.Version, compact, logo.Opts{
2995 FieldColor: t.LogoFieldColor,
2996 TitleColorA: t.LogoTitleColorA,
2997 TitleColorB: t.LogoTitleColorB,
2998 CharmColor: t.LogoCharmColor,
2999 VersionColor: t.LogoVersionColor,
3000 Width: width,
3001 })
3002}