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 if len(m.attachments.List()) == 0 {
2251 layout.editor.Min.Y += 1
2252 }
2253 layout.editor.Max.Y -= 1
2254 }
2255
2256 return layout
2257}
2258
2259// layout defines the positioning of UI elements.
2260type layout struct {
2261 // area is the overall available area.
2262 area uv.Rectangle
2263
2264 // header is the header shown in special cases
2265 // e.x when the sidebar is collapsed
2266 // or when in the landing page
2267 // or in init/config
2268 header uv.Rectangle
2269
2270 // main is the area for the main pane. (e.x chat, configure, landing)
2271 main uv.Rectangle
2272
2273 // pills is the area for the pills panel.
2274 pills uv.Rectangle
2275
2276 // editor is the area for the editor pane.
2277 editor uv.Rectangle
2278
2279 // sidebar is the area for the sidebar.
2280 sidebar uv.Rectangle
2281
2282 // status is the area for the status view.
2283 status uv.Rectangle
2284
2285 // session details is the area for the session details overlay in compact mode.
2286 sessionDetails uv.Rectangle
2287}
2288
2289func (m *UI) openEditor(value string) tea.Cmd {
2290 tmpfile, err := os.CreateTemp("", "msg_*.md")
2291 if err != nil {
2292 return uiutil.ReportError(err)
2293 }
2294 defer tmpfile.Close() //nolint:errcheck
2295 if _, err := tmpfile.WriteString(value); err != nil {
2296 return uiutil.ReportError(err)
2297 }
2298 cmd, err := editor.Command(
2299 "crush",
2300 tmpfile.Name(),
2301 editor.AtPosition(
2302 m.textarea.Line()+1,
2303 m.textarea.Column()+1,
2304 ),
2305 )
2306 if err != nil {
2307 return uiutil.ReportError(err)
2308 }
2309 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2310 if err != nil {
2311 return uiutil.ReportError(err)
2312 }
2313 content, err := os.ReadFile(tmpfile.Name())
2314 if err != nil {
2315 return uiutil.ReportError(err)
2316 }
2317 if len(content) == 0 {
2318 return uiutil.ReportWarn("Message is empty")
2319 }
2320 os.Remove(tmpfile.Name())
2321 return openEditorMsg{
2322 Text: strings.TrimSpace(string(content)),
2323 }
2324 })
2325}
2326
2327// setEditorPrompt configures the textarea prompt function based on whether
2328// yolo mode is enabled.
2329func (m *UI) setEditorPrompt(yolo bool) {
2330 if yolo {
2331 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2332 return
2333 }
2334 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2335}
2336
2337// normalPromptFunc returns the normal editor prompt style (" > " on first
2338// line, "::: " on subsequent lines).
2339func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2340 t := m.com.Styles
2341 if info.LineNumber == 0 {
2342 if info.Focused {
2343 return " > "
2344 }
2345 return "::: "
2346 }
2347 if info.Focused {
2348 return t.EditorPromptNormalFocused.Render()
2349 }
2350 return t.EditorPromptNormalBlurred.Render()
2351}
2352
2353// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2354// and colored dots.
2355func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2356 t := m.com.Styles
2357 if info.LineNumber == 0 {
2358 if info.Focused {
2359 return t.EditorPromptYoloIconFocused.Render()
2360 } else {
2361 return t.EditorPromptYoloIconBlurred.Render()
2362 }
2363 }
2364 if info.Focused {
2365 return t.EditorPromptYoloDotsFocused.Render()
2366 }
2367 return t.EditorPromptYoloDotsBlurred.Render()
2368}
2369
2370// closeCompletions closes the completions popup and resets state.
2371func (m *UI) closeCompletions() {
2372 m.completionsOpen = false
2373 m.completionsQuery = ""
2374 m.completionsStartIndex = 0
2375 m.completions.Close()
2376}
2377
2378// insertFileCompletion inserts the selected file path into the textarea,
2379// replacing the @query, and adds the file as an attachment.
2380func (m *UI) insertFileCompletion(path string) tea.Cmd {
2381 value := m.textarea.Value()
2382 word := m.textareaWord()
2383
2384 // Find the @ and query to replace.
2385 if m.completionsStartIndex > len(value) {
2386 return nil
2387 }
2388
2389 // Build the new value: everything before @, the path, everything after query.
2390 endIdx := min(m.completionsStartIndex+len(word), len(value))
2391
2392 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2393 m.textarea.SetValue(newValue)
2394 m.textarea.MoveToEnd()
2395 m.textarea.InsertRune(' ')
2396
2397 return func() tea.Msg {
2398 absPath, _ := filepath.Abs(path)
2399 // Skip attachment if file was already read and hasn't been modified.
2400 lastRead := filetracker.LastReadTime(absPath)
2401 if !lastRead.IsZero() {
2402 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2403 return nil
2404 }
2405 }
2406
2407 // Add file as attachment.
2408 content, err := os.ReadFile(path)
2409 if err != nil {
2410 // If it fails, let the LLM handle it later.
2411 return nil
2412 }
2413 filetracker.RecordRead(absPath)
2414
2415 return message.Attachment{
2416 FilePath: path,
2417 FileName: filepath.Base(path),
2418 MimeType: mimeOf(content),
2419 Content: content,
2420 }
2421 }
2422}
2423
2424// completionsPosition returns the X and Y position for the completions popup.
2425func (m *UI) completionsPosition() image.Point {
2426 cur := m.textarea.Cursor()
2427 if cur == nil {
2428 return image.Point{
2429 X: m.layout.editor.Min.X,
2430 Y: m.layout.editor.Min.Y,
2431 }
2432 }
2433 return image.Point{
2434 X: cur.X + m.layout.editor.Min.X,
2435 Y: m.layout.editor.Min.Y + cur.Y,
2436 }
2437}
2438
2439// textareaWord returns the current word at the cursor position.
2440func (m *UI) textareaWord() string {
2441 return m.textarea.Word()
2442}
2443
2444// isWhitespace returns true if the byte is a whitespace character.
2445func isWhitespace(b byte) bool {
2446 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2447}
2448
2449// isAgentBusy returns true if the agent coordinator exists and is currently
2450// busy processing a request.
2451func (m *UI) isAgentBusy() bool {
2452 return m.com.App != nil &&
2453 m.com.App.AgentCoordinator != nil &&
2454 m.com.App.AgentCoordinator.IsBusy()
2455}
2456
2457// hasSession returns true if there is an active session with a valid ID.
2458func (m *UI) hasSession() bool {
2459 return m.session != nil && m.session.ID != ""
2460}
2461
2462// mimeOf detects the MIME type of the given content.
2463func mimeOf(content []byte) string {
2464 mimeBufferSize := min(512, len(content))
2465 return http.DetectContentType(content[:mimeBufferSize])
2466}
2467
2468var readyPlaceholders = [...]string{
2469 "Ready!",
2470 "Ready...",
2471 "Ready?",
2472 "Ready for instructions",
2473}
2474
2475var workingPlaceholders = [...]string{
2476 "Working!",
2477 "Working...",
2478 "Brrrrr...",
2479 "Prrrrrrrr...",
2480 "Processing...",
2481 "Thinking...",
2482}
2483
2484// randomizePlaceholders selects random placeholder text for the textarea's
2485// ready and working states.
2486func (m *UI) randomizePlaceholders() {
2487 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2488 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2489}
2490
2491// renderEditorView renders the editor view with attachments if any.
2492func (m *UI) renderEditorView(width int) string {
2493 if len(m.attachments.List()) == 0 {
2494 return m.textarea.View()
2495 }
2496 return lipgloss.JoinVertical(
2497 lipgloss.Top,
2498 m.attachments.Render(width),
2499 m.textarea.View(),
2500 )
2501}
2502
2503// renderHeader renders and caches the header logo at the specified width.
2504func (m *UI) renderHeader(compact bool, width int) {
2505 if compact && m.session != nil && m.com.App != nil {
2506 m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2507 } else {
2508 m.header = renderLogo(m.com.Styles, compact, width)
2509 }
2510}
2511
2512// renderSidebarLogo renders and caches the sidebar logo at the specified
2513// width.
2514func (m *UI) renderSidebarLogo(width int) {
2515 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2516}
2517
2518// sendMessage sends a message with the given content and attachments.
2519func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2520 if m.com.App.AgentCoordinator == nil {
2521 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2522 }
2523
2524 var cmds []tea.Cmd
2525 if !m.hasSession() {
2526 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2527 if err != nil {
2528 return uiutil.ReportError(err)
2529 }
2530 m.state = uiChat
2531 if m.forceCompactMode {
2532 m.isCompact = true
2533 }
2534 if newSession.ID != "" {
2535 m.session = &newSession
2536 cmds = append(cmds, m.loadSession(newSession.ID))
2537 }
2538 }
2539
2540 // Capture session ID to avoid race with main goroutine updating m.session.
2541 sessionID := m.session.ID
2542 cmds = append(cmds, func() tea.Msg {
2543 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2544 if err != nil {
2545 isCancelErr := errors.Is(err, context.Canceled)
2546 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2547 if isCancelErr || isPermissionErr {
2548 return nil
2549 }
2550 return uiutil.InfoMsg{
2551 Type: uiutil.InfoTypeError,
2552 Msg: err.Error(),
2553 }
2554 }
2555 return nil
2556 })
2557 return tea.Batch(cmds...)
2558}
2559
2560const cancelTimerDuration = 2 * time.Second
2561
2562// cancelTimerCmd creates a command that expires the cancel timer.
2563func cancelTimerCmd() tea.Cmd {
2564 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2565 return cancelTimerExpiredMsg{}
2566 })
2567}
2568
2569// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2570// and starts a timer. The second press (before the timer expires) actually
2571// cancels the agent.
2572func (m *UI) cancelAgent() tea.Cmd {
2573 if !m.hasSession() {
2574 return nil
2575 }
2576
2577 coordinator := m.com.App.AgentCoordinator
2578 if coordinator == nil {
2579 return nil
2580 }
2581
2582 if m.isCanceling {
2583 // Second escape press - actually cancel the agent.
2584 m.isCanceling = false
2585 coordinator.Cancel(m.session.ID)
2586 // Stop the spinning todo indicator.
2587 m.todoIsSpinning = false
2588 m.renderPills()
2589 return nil
2590 }
2591
2592 // Check if there are queued prompts - if so, clear the queue.
2593 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2594 coordinator.ClearQueue(m.session.ID)
2595 return nil
2596 }
2597
2598 // First escape press - set canceling state and start timer.
2599 m.isCanceling = true
2600 return cancelTimerCmd()
2601}
2602
2603// openDialog opens a dialog by its ID.
2604func (m *UI) openDialog(id string) tea.Cmd {
2605 var cmds []tea.Cmd
2606 switch id {
2607 case dialog.SessionsID:
2608 if cmd := m.openSessionsDialog(); cmd != nil {
2609 cmds = append(cmds, cmd)
2610 }
2611 case dialog.ModelsID:
2612 if cmd := m.openModelsDialog(); cmd != nil {
2613 cmds = append(cmds, cmd)
2614 }
2615 case dialog.CommandsID:
2616 if cmd := m.openCommandsDialog(); cmd != nil {
2617 cmds = append(cmds, cmd)
2618 }
2619 case dialog.ReasoningID:
2620 if cmd := m.openReasoningDialog(); cmd != nil {
2621 cmds = append(cmds, cmd)
2622 }
2623 case dialog.QuitID:
2624 if cmd := m.openQuitDialog(); cmd != nil {
2625 cmds = append(cmds, cmd)
2626 }
2627 default:
2628 // Unknown dialog
2629 break
2630 }
2631 return tea.Batch(cmds...)
2632}
2633
2634// openQuitDialog opens the quit confirmation dialog.
2635func (m *UI) openQuitDialog() tea.Cmd {
2636 if m.dialog.ContainsDialog(dialog.QuitID) {
2637 // Bring to front
2638 m.dialog.BringToFront(dialog.QuitID)
2639 return nil
2640 }
2641
2642 quitDialog := dialog.NewQuit(m.com)
2643 m.dialog.OpenDialog(quitDialog)
2644 return nil
2645}
2646
2647// openModelsDialog opens the models dialog.
2648func (m *UI) openModelsDialog() tea.Cmd {
2649 if m.dialog.ContainsDialog(dialog.ModelsID) {
2650 // Bring to front
2651 m.dialog.BringToFront(dialog.ModelsID)
2652 return nil
2653 }
2654
2655 isOnboarding := m.state == uiOnboarding
2656 modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2657 if err != nil {
2658 return uiutil.ReportError(err)
2659 }
2660
2661 m.dialog.OpenDialog(modelsDialog)
2662
2663 return nil
2664}
2665
2666// openCommandsDialog opens the commands dialog.
2667func (m *UI) openCommandsDialog() tea.Cmd {
2668 if m.dialog.ContainsDialog(dialog.CommandsID) {
2669 // Bring to front
2670 m.dialog.BringToFront(dialog.CommandsID)
2671 return nil
2672 }
2673
2674 sessionID := ""
2675 if m.session != nil {
2676 sessionID = m.session.ID
2677 }
2678
2679 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2680 if err != nil {
2681 return uiutil.ReportError(err)
2682 }
2683
2684 m.dialog.OpenDialog(commands)
2685
2686 return nil
2687}
2688
2689// openReasoningDialog opens the reasoning effort dialog.
2690func (m *UI) openReasoningDialog() tea.Cmd {
2691 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2692 m.dialog.BringToFront(dialog.ReasoningID)
2693 return nil
2694 }
2695
2696 reasoningDialog, err := dialog.NewReasoning(m.com)
2697 if err != nil {
2698 return uiutil.ReportError(err)
2699 }
2700
2701 m.dialog.OpenDialog(reasoningDialog)
2702 return nil
2703}
2704
2705// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2706// it brings it to the front. Otherwise, it will list all the sessions and open
2707// the dialog.
2708func (m *UI) openSessionsDialog() tea.Cmd {
2709 if m.dialog.ContainsDialog(dialog.SessionsID) {
2710 // Bring to front
2711 m.dialog.BringToFront(dialog.SessionsID)
2712 return nil
2713 }
2714
2715 selectedSessionID := ""
2716 if m.session != nil {
2717 selectedSessionID = m.session.ID
2718 }
2719
2720 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2721 if err != nil {
2722 return uiutil.ReportError(err)
2723 }
2724
2725 m.dialog.OpenDialog(dialog)
2726 return nil
2727}
2728
2729// openFilesDialog opens the file picker dialog.
2730func (m *UI) openFilesDialog() tea.Cmd {
2731 if m.dialog.ContainsDialog(dialog.FilePickerID) {
2732 // Bring to front
2733 m.dialog.BringToFront(dialog.FilePickerID)
2734 return nil
2735 }
2736
2737 filePicker, cmd := dialog.NewFilePicker(m.com)
2738 filePicker.SetImageCapabilities(&m.imgCaps)
2739 m.dialog.OpenDialog(filePicker)
2740
2741 return cmd
2742}
2743
2744// openPermissionsDialog opens the permissions dialog for a permission request.
2745func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2746 // Close any existing permissions dialog first.
2747 m.dialog.CloseDialog(dialog.PermissionsID)
2748
2749 // Get diff mode from config.
2750 var opts []dialog.PermissionsOption
2751 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2752 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2753 }
2754
2755 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2756 m.dialog.OpenDialog(permDialog)
2757 return nil
2758}
2759
2760// handlePermissionNotification updates tool items when permission state changes.
2761func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2762 toolItem := m.chat.MessageItem(notification.ToolCallID)
2763 if toolItem == nil {
2764 return
2765 }
2766
2767 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2768 if notification.Granted {
2769 permItem.SetStatus(chat.ToolStatusRunning)
2770 } else {
2771 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2772 }
2773 }
2774}
2775
2776// newSession clears the current session state and prepares for a new session.
2777// The actual session creation happens when the user sends their first message.
2778func (m *UI) newSession() {
2779 if !m.hasSession() {
2780 return
2781 }
2782
2783 m.session = nil
2784 m.sessionFiles = nil
2785 m.state = uiLanding
2786 m.focus = uiFocusEditor
2787 m.textarea.Focus()
2788 m.chat.Blur()
2789 m.chat.ClearMessages()
2790 m.pillsExpanded = false
2791 m.promptQueue = 0
2792 m.pillsView = ""
2793}
2794
2795// handlePasteMsg handles a paste message.
2796func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2797 if m.dialog.HasDialogs() {
2798 return m.handleDialogMsg(msg)
2799 }
2800
2801 if m.focus != uiFocusEditor {
2802 return nil
2803 }
2804
2805 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
2806 return func() tea.Msg {
2807 content := []byte(msg.Content)
2808 if int64(len(content)) > common.MaxAttachmentSize {
2809 return uiutil.ReportWarn("Paste is too big (>5mb)")
2810 }
2811 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2812 mimeBufferSize := min(512, len(content))
2813 mimeType := http.DetectContentType(content[:mimeBufferSize])
2814 return message.Attachment{
2815 FileName: name,
2816 FilePath: name,
2817 MimeType: mimeType,
2818 Content: content,
2819 }
2820 }
2821 }
2822
2823 // Attempt to parse pasted content as file paths. If possible to parse,
2824 // all files exist and are valid, add as attachments.
2825 // Otherwise, paste as text.
2826 paths := fsext.PasteStringToPaths(msg.Content)
2827 allExistsAndValid := func() bool {
2828 for _, path := range paths {
2829 if _, err := os.Stat(path); os.IsNotExist(err) {
2830 return false
2831 }
2832
2833 lowerPath := strings.ToLower(path)
2834 isValid := false
2835 for _, ext := range common.AllowedImageTypes {
2836 if strings.HasSuffix(lowerPath, ext) {
2837 isValid = true
2838 break
2839 }
2840 }
2841 if !isValid {
2842 return false
2843 }
2844 }
2845 return true
2846 }
2847 if !allExistsAndValid() {
2848 var cmd tea.Cmd
2849 m.textarea, cmd = m.textarea.Update(msg)
2850 return cmd
2851 }
2852
2853 var cmds []tea.Cmd
2854 for _, path := range paths {
2855 cmds = append(cmds, m.handleFilePathPaste(path))
2856 }
2857 return tea.Batch(cmds...)
2858}
2859
2860// handleFilePathPaste handles a pasted file path.
2861func (m *UI) handleFilePathPaste(path string) tea.Cmd {
2862 return func() tea.Msg {
2863 fileInfo, err := os.Stat(path)
2864 if err != nil {
2865 return uiutil.ReportError(err)
2866 }
2867 if fileInfo.Size() > common.MaxAttachmentSize {
2868 return uiutil.ReportWarn("File is too big (>5mb)")
2869 }
2870
2871 content, err := os.ReadFile(path)
2872 if err != nil {
2873 return uiutil.ReportError(err)
2874 }
2875
2876 mimeBufferSize := min(512, len(content))
2877 mimeType := http.DetectContentType(content[:mimeBufferSize])
2878 fileName := filepath.Base(path)
2879 return message.Attachment{
2880 FilePath: path,
2881 FileName: fileName,
2882 MimeType: mimeType,
2883 Content: content,
2884 }
2885 }
2886}
2887
2888var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2889
2890func (m *UI) pasteIdx() int {
2891 result := 0
2892 for _, at := range m.attachments.List() {
2893 found := pasteRE.FindStringSubmatch(at.FileName)
2894 if len(found) == 0 {
2895 continue
2896 }
2897 idx, err := strconv.Atoi(found[1])
2898 if err == nil {
2899 result = max(result, idx)
2900 }
2901 }
2902 return result + 1
2903}
2904
2905// drawSessionDetails draws the session details in compact mode.
2906func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2907 if m.session == nil {
2908 return
2909 }
2910
2911 s := m.com.Styles
2912
2913 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2914 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2915
2916 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2917 blocks := []string{
2918 title,
2919 "",
2920 m.modelInfo(width),
2921 "",
2922 }
2923
2924 detailsHeader := lipgloss.JoinVertical(
2925 lipgloss.Left,
2926 blocks...,
2927 )
2928
2929 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2930
2931 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2932
2933 const maxSectionWidth = 50
2934 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2935 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2936
2937 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2938 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2939 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2940 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2941 uv.NewStyledString(
2942 s.CompactDetails.View.
2943 Width(area.Dx()).
2944 Render(
2945 lipgloss.JoinVertical(
2946 lipgloss.Left,
2947 detailsHeader,
2948 sections,
2949 version,
2950 ),
2951 ),
2952 ).Draw(scr, area)
2953}
2954
2955func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2956 load := func() tea.Msg {
2957 prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2958 if err != nil {
2959 // TODO: make this better
2960 return uiutil.ReportError(err)()
2961 }
2962
2963 if prompt == "" {
2964 return nil
2965 }
2966 return sendMessageMsg{
2967 Content: prompt,
2968 }
2969 }
2970
2971 var cmds []tea.Cmd
2972 if cmd := m.dialog.StartLoading(); cmd != nil {
2973 cmds = append(cmds, cmd)
2974 }
2975 cmds = append(cmds, load, func() tea.Msg {
2976 return closeDialogMsg{}
2977 })
2978
2979 return tea.Sequence(cmds...)
2980}
2981
2982func (m *UI) copyChatHighlight() tea.Cmd {
2983 text := m.chat.HighlightContent()
2984 return common.CopyToClipboardWithCallback(
2985 text,
2986 "Selected text copied to clipboard",
2987 func() tea.Msg {
2988 m.chat.ClearMouse()
2989 return nil
2990 },
2991 )
2992}
2993
2994// renderLogo renders the Crush logo with the given styles and dimensions.
2995func renderLogo(t *styles.Styles, compact bool, width int) string {
2996 return logo.Render(version.Version, compact, logo.Opts{
2997 FieldColor: t.LogoFieldColor,
2998 TitleColorA: t.LogoTitleColorA,
2999 TitleColorB: t.LogoTitleColorB,
3000 CharmColor: t.LogoCharmColor,
3001 VersionColor: t.LogoVersionColor,
3002 Width: width,
3003 })
3004}