1package model
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "image"
8 "log/slog"
9 "math/rand"
10 "net/http"
11 "os"
12 "path/filepath"
13 "regexp"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "charm.land/bubbles/v2/help"
20 "charm.land/bubbles/v2/key"
21 "charm.land/bubbles/v2/spinner"
22 "charm.land/bubbles/v2/textarea"
23 tea "charm.land/bubbletea/v2"
24 "charm.land/lipgloss/v2"
25 "github.com/charmbracelet/catwalk/pkg/catwalk"
26 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
27 "github.com/charmbracelet/crush/internal/app"
28 "github.com/charmbracelet/crush/internal/commands"
29 "github.com/charmbracelet/crush/internal/config"
30 "github.com/charmbracelet/crush/internal/filetracker"
31 "github.com/charmbracelet/crush/internal/history"
32 "github.com/charmbracelet/crush/internal/message"
33 "github.com/charmbracelet/crush/internal/permission"
34 "github.com/charmbracelet/crush/internal/pubsub"
35 "github.com/charmbracelet/crush/internal/session"
36 "github.com/charmbracelet/crush/internal/ui/anim"
37 "github.com/charmbracelet/crush/internal/ui/attachments"
38 "github.com/charmbracelet/crush/internal/ui/chat"
39 "github.com/charmbracelet/crush/internal/ui/common"
40 "github.com/charmbracelet/crush/internal/ui/completions"
41 "github.com/charmbracelet/crush/internal/ui/dialog"
42 "github.com/charmbracelet/crush/internal/ui/logo"
43 "github.com/charmbracelet/crush/internal/ui/styles"
44 "github.com/charmbracelet/crush/internal/uiutil"
45 "github.com/charmbracelet/crush/internal/version"
46 uv "github.com/charmbracelet/ultraviolet"
47 "github.com/charmbracelet/ultraviolet/screen"
48 "github.com/charmbracelet/x/editor"
49)
50
51// Max file size set to 5M.
52const maxAttachmentSize = int64(5 * 1024 * 1024)
53
54// Allowed image formats.
55var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
56
57// Compact mode breakpoints.
58const (
59 compactModeWidthBreakpoint = 120
60 compactModeHeightBreakpoint = 30
61)
62
63// Session details panel max height.
64const sessionDetailsMaxHeight = 20
65
66// uiFocusState represents the current focus state of the UI.
67type uiFocusState uint8
68
69// Possible uiFocusState values.
70const (
71 uiFocusNone uiFocusState = iota
72 uiFocusEditor
73 uiFocusMain
74)
75
76type uiState uint8
77
78// Possible uiState values.
79const (
80 uiConfigure uiState = iota
81 uiInitialize
82 uiLanding
83 uiChat
84)
85
86type openEditorMsg struct {
87 Text string
88}
89
90type (
91 // cancelTimerExpiredMsg is sent when the cancel timer expires.
92 cancelTimerExpiredMsg struct{}
93 // userCommandsLoadedMsg is sent when user commands are loaded.
94 userCommandsLoadedMsg struct {
95 Commands []commands.CustomCommand
96 }
97 // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
98 mcpPromptsLoadedMsg struct {
99 Prompts []commands.MCPPrompt
100 }
101 // sendMessageMsg is sent to send a message.
102 // currently only used for mcp prompts.
103 sendMessageMsg struct {
104 Content string
105 Attachments []message.Attachment
106 }
107
108 // closeDialogMsg is sent to close the current dialog.
109 closeDialogMsg struct{}
110)
111
112// UI represents the main user interface model.
113type UI struct {
114 com *common.Common
115 session *session.Session
116 sessionFiles []SessionFile
117
118 // The width and height of the terminal in cells.
119 width int
120 height int
121 layout layout
122
123 focus uiFocusState
124 state uiState
125
126 keyMap KeyMap
127 keyenh tea.KeyboardEnhancementsMsg
128
129 dialog *dialog.Overlay
130 status *Status
131
132 // isCanceling tracks whether the user has pressed escape once to cancel.
133 isCanceling bool
134
135 // header is the last cached header logo
136 header string
137
138 // sendProgressBar instructs the TUI to send progress bar updates to the
139 // terminal.
140 sendProgressBar bool
141
142 // QueryVersion instructs the TUI to query for the terminal version when it
143 // starts.
144 QueryVersion bool
145
146 // Editor components
147 textarea textarea.Model
148
149 // Attachment list
150 attachments *attachments.Attachments
151
152 readyPlaceholder string
153 workingPlaceholder string
154
155 // Completions state
156 completions *completions.Completions
157 completionsOpen bool
158 completionsStartIndex int
159 completionsQuery string
160 completionsPositionStart image.Point // x,y where user typed '@'
161
162 // Chat components
163 chat *Chat
164
165 // onboarding state
166 onboarding struct {
167 yesInitializeSelected bool
168 }
169
170 // lsp
171 lspStates map[string]app.LSPClientInfo
172
173 // mcp
174 mcpStates map[string]mcp.ClientInfo
175
176 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
177 sidebarLogo string
178
179 // custom commands & mcp commands
180 customCommands []commands.CustomCommand
181 mcpPrompts []commands.MCPPrompt
182
183 // forceCompactMode tracks whether compact mode is forced by user toggle
184 forceCompactMode bool
185
186 // isCompact tracks whether we're currently in compact layout mode (either
187 // by user toggle or auto-switch based on window size)
188 isCompact bool
189
190 // detailsOpen tracks whether the details panel is open (in compact mode)
191 detailsOpen bool
192}
193
194// New creates a new instance of the [UI] model.
195func New(com *common.Common) *UI {
196 // Editor components
197 ta := textarea.New()
198 ta.SetStyles(com.Styles.TextArea)
199 ta.ShowLineNumbers = false
200 ta.CharLimit = -1
201 ta.SetVirtualCursor(false)
202 ta.Focus()
203
204 ch := NewChat(com)
205
206 keyMap := DefaultKeyMap()
207
208 // Completions component
209 comp := completions.New(
210 com.Styles.Completions.Normal,
211 com.Styles.Completions.Focused,
212 com.Styles.Completions.Match,
213 )
214
215 // Attachments component
216 attachments := attachments.New(
217 attachments.NewRenderer(
218 com.Styles.Attachments.Normal,
219 com.Styles.Attachments.Deleting,
220 com.Styles.Attachments.Image,
221 com.Styles.Attachments.Text,
222 ),
223 attachments.Keymap{
224 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
225 DeleteAll: keyMap.Editor.DeleteAllAttachments,
226 Escape: keyMap.Editor.Escape,
227 },
228 )
229
230 ui := &UI{
231 com: com,
232 dialog: dialog.NewOverlay(),
233 keyMap: keyMap,
234 focus: uiFocusNone,
235 state: uiConfigure,
236 textarea: ta,
237 chat: ch,
238 completions: comp,
239 attachments: attachments,
240 }
241
242 status := NewStatus(com, ui)
243
244 // set onboarding state defaults
245 ui.onboarding.yesInitializeSelected = true
246
247 // If no provider is configured show the user the provider list
248 if !com.Config().IsConfigured() {
249 ui.state = uiConfigure
250 // if the project needs initialization show the user the question
251 } else if n, _ := config.ProjectNeedsInitialization(); n {
252 ui.state = uiInitialize
253 // otherwise go to the landing UI
254 } else {
255 ui.state = uiLanding
256 ui.focus = uiFocusEditor
257 }
258
259 ui.setEditorPrompt(false)
260 ui.randomizePlaceholders()
261 ui.textarea.Placeholder = ui.readyPlaceholder
262 ui.status = status
263
264 // Initialize compact mode from config
265 ui.forceCompactMode = com.Config().Options.TUI.CompactMode
266
267 return ui
268}
269
270// Init initializes the UI model.
271func (m *UI) Init() tea.Cmd {
272 var cmds []tea.Cmd
273 if m.QueryVersion {
274 cmds = append(cmds, tea.RequestTerminalVersion)
275 }
276 // load the user commands async
277 cmds = append(cmds, m.loadCustomCommands())
278 return tea.Batch(cmds...)
279}
280
281// loadCustomCommands loads the custom commands asynchronously.
282func (m *UI) loadCustomCommands() tea.Cmd {
283 return func() tea.Msg {
284 customCommands, err := commands.LoadCustomCommands(m.com.Config())
285 if err != nil {
286 slog.Error("failed to load custom commands", "error", err)
287 }
288 return userCommandsLoadedMsg{Commands: customCommands}
289 }
290}
291
292// loadMCPrompts loads the MCP prompts asynchronously.
293func (m *UI) loadMCPrompts() tea.Cmd {
294 return func() tea.Msg {
295 prompts, err := commands.LoadMCPPrompts()
296 if err != nil {
297 slog.Error("failed to load mcp prompts", "error", err)
298 }
299 if prompts == nil {
300 // flag them as loaded even if there is none or an error
301 prompts = []commands.MCPPrompt{}
302 }
303 return mcpPromptsLoadedMsg{Prompts: prompts}
304 }
305}
306
307// Update handles updates to the UI model.
308func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
309 var cmds []tea.Cmd
310 switch msg := msg.(type) {
311 case tea.EnvMsg:
312 // Is this Windows Terminal?
313 if !m.sendProgressBar {
314 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
315 }
316 case loadSessionMsg:
317 m.state = uiChat
318 if m.forceCompactMode {
319 m.isCompact = true
320 }
321 m.session = msg.session
322 m.sessionFiles = msg.files
323 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
324 if err != nil {
325 cmds = append(cmds, uiutil.ReportError(err))
326 break
327 }
328 if cmd := m.setSessionMessages(msgs); cmd != nil {
329 cmds = append(cmds, cmd)
330 }
331
332 case sendMessageMsg:
333 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
334
335 case userCommandsLoadedMsg:
336 m.customCommands = msg.Commands
337 dia := m.dialog.Dialog(dialog.CommandsID)
338 if dia == nil {
339 break
340 }
341
342 commands, ok := dia.(*dialog.Commands)
343 if ok {
344 commands.SetCustomCommands(m.customCommands)
345 }
346 case mcpPromptsLoadedMsg:
347 m.mcpPrompts = msg.Prompts
348 dia := m.dialog.Dialog(dialog.CommandsID)
349 if dia == nil {
350 break
351 }
352
353 commands, ok := dia.(*dialog.Commands)
354 if ok {
355 commands.SetMCPPrompts(m.mcpPrompts)
356 }
357
358 case closeDialogMsg:
359 m.dialog.CloseFrontDialog()
360
361 case pubsub.Event[message.Message]:
362 // Check if this is a child session message for an agent tool.
363 if m.session == nil {
364 break
365 }
366 if msg.Payload.SessionID != m.session.ID {
367 // This might be a child session message from an agent tool.
368 if cmd := m.handleChildSessionMessage(msg); cmd != nil {
369 cmds = append(cmds, cmd)
370 }
371 break
372 }
373 switch msg.Type {
374 case pubsub.CreatedEvent:
375 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
376 case pubsub.UpdatedEvent:
377 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
378 }
379 case pubsub.Event[history.File]:
380 cmds = append(cmds, m.handleFileEvent(msg.Payload))
381 case pubsub.Event[app.LSPEvent]:
382 m.lspStates = app.GetLSPStates()
383 case pubsub.Event[mcp.Event]:
384 m.mcpStates = mcp.GetStates()
385 // check if all mcps are initialized
386 initialized := true
387 for _, state := range m.mcpStates {
388 if state.State == mcp.StateStarting {
389 initialized = false
390 break
391 }
392 }
393 if initialized && m.mcpPrompts == nil {
394 cmds = append(cmds, m.loadMCPrompts())
395 }
396 case pubsub.Event[permission.PermissionRequest]:
397 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
398 cmds = append(cmds, cmd)
399 }
400 case pubsub.Event[permission.PermissionNotification]:
401 m.handlePermissionNotification(msg.Payload)
402 case cancelTimerExpiredMsg:
403 m.isCanceling = false
404 case tea.TerminalVersionMsg:
405 termVersion := strings.ToLower(msg.Name)
406 // Only enable progress bar for the following terminals.
407 if !m.sendProgressBar {
408 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
409 }
410 return m, nil
411 case tea.WindowSizeMsg:
412 m.width, m.height = msg.Width, msg.Height
413 m.handleCompactMode(m.width, m.height)
414 m.updateLayoutAndSize()
415 case tea.KeyboardEnhancementsMsg:
416 m.keyenh = msg
417 if msg.SupportsKeyDisambiguation() {
418 m.keyMap.Models.SetHelp("ctrl+m", "models")
419 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
420 }
421 case tea.MouseClickMsg:
422 switch m.state {
423 case uiChat:
424 x, y := msg.X, msg.Y
425 // Adjust for chat area position
426 x -= m.layout.main.Min.X
427 y -= m.layout.main.Min.Y
428 m.chat.HandleMouseDown(x, y)
429 }
430
431 case tea.MouseMotionMsg:
432 switch m.state {
433 case uiChat:
434 if msg.Y <= 0 {
435 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
436 cmds = append(cmds, cmd)
437 }
438 if !m.chat.SelectedItemInView() {
439 m.chat.SelectPrev()
440 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
441 cmds = append(cmds, cmd)
442 }
443 }
444 } else if msg.Y >= m.chat.Height()-1 {
445 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
446 cmds = append(cmds, cmd)
447 }
448 if !m.chat.SelectedItemInView() {
449 m.chat.SelectNext()
450 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
451 cmds = append(cmds, cmd)
452 }
453 }
454 }
455
456 x, y := msg.X, msg.Y
457 // Adjust for chat area position
458 x -= m.layout.main.Min.X
459 y -= m.layout.main.Min.Y
460 m.chat.HandleMouseDrag(x, y)
461 }
462
463 case tea.MouseReleaseMsg:
464 switch m.state {
465 case uiChat:
466 x, y := msg.X, msg.Y
467 // Adjust for chat area position
468 x -= m.layout.main.Min.X
469 y -= m.layout.main.Min.Y
470 m.chat.HandleMouseUp(x, y)
471 }
472 case tea.MouseWheelMsg:
473 // Pass mouse events to dialogs first if any are open.
474 if m.dialog.HasDialogs() {
475 m.dialog.Update(msg)
476 return m, tea.Batch(cmds...)
477 }
478
479 // Otherwise handle mouse wheel for chat.
480 switch m.state {
481 case uiChat:
482 switch msg.Button {
483 case tea.MouseWheelUp:
484 if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
485 cmds = append(cmds, cmd)
486 }
487 if !m.chat.SelectedItemInView() {
488 m.chat.SelectPrev()
489 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
490 cmds = append(cmds, cmd)
491 }
492 }
493 case tea.MouseWheelDown:
494 if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
495 cmds = append(cmds, cmd)
496 }
497 if !m.chat.SelectedItemInView() {
498 m.chat.SelectNext()
499 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
500 cmds = append(cmds, cmd)
501 }
502 }
503 }
504 }
505 case anim.StepMsg:
506 if m.state == uiChat {
507 if cmd := m.chat.Animate(msg); cmd != nil {
508 cmds = append(cmds, cmd)
509 }
510 }
511 case spinner.TickMsg:
512 if m.dialog.HasDialogs() {
513 // route to dialog
514 if cmd := m.handleDialogMsg(msg); cmd != nil {
515 cmds = append(cmds, cmd)
516 }
517 }
518
519 case tea.KeyPressMsg:
520 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
521 cmds = append(cmds, cmd)
522 }
523 case tea.PasteMsg:
524 if cmd := m.handlePasteMsg(msg); cmd != nil {
525 cmds = append(cmds, cmd)
526 }
527 case openEditorMsg:
528 m.textarea.SetValue(msg.Text)
529 m.textarea.MoveToEnd()
530 case uiutil.InfoMsg:
531 m.status.SetInfoMsg(msg)
532 ttl := msg.TTL
533 if ttl <= 0 {
534 ttl = DefaultStatusTTL
535 }
536 cmds = append(cmds, clearInfoMsgCmd(ttl))
537 case uiutil.ClearStatusMsg:
538 m.status.ClearInfoMsg()
539 case completions.FilesLoadedMsg:
540 // Handle async file loading for completions.
541 if m.completionsOpen {
542 m.completions.SetFiles(msg.Files)
543 }
544 default:
545 if m.dialog.HasDialogs() {
546 if cmd := m.handleDialogMsg(msg); cmd != nil {
547 cmds = append(cmds, cmd)
548 }
549 }
550 }
551
552 // This logic gets triggered on any message type, but should it?
553 switch m.focus {
554 case uiFocusMain:
555 case uiFocusEditor:
556 // Textarea placeholder logic
557 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
558 m.textarea.Placeholder = m.workingPlaceholder
559 } else {
560 m.textarea.Placeholder = m.readyPlaceholder
561 }
562 if m.com.App.Permissions.SkipRequests() {
563 m.textarea.Placeholder = "Yolo mode!"
564 }
565 }
566
567 // at this point this can only handle [message.Attachment] message, and we
568 // should return all cmds anyway.
569 _ = m.attachments.Update(msg)
570 return m, tea.Batch(cmds...)
571}
572
573// setSessionMessages sets the messages for the current session in the chat
574func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
575 var cmds []tea.Cmd
576 // Build tool result map to link tool calls with their results
577 msgPtrs := make([]*message.Message, len(msgs))
578 for i := range msgs {
579 msgPtrs[i] = &msgs[i]
580 }
581 toolResultMap := chat.BuildToolResultMap(msgPtrs)
582
583 // Add messages to chat with linked tool results
584 items := make([]chat.MessageItem, 0, len(msgs)*2)
585 for _, msg := range msgPtrs {
586 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
587 }
588
589 // Load nested tool calls for agent/agentic_fetch tools.
590 m.loadNestedToolCalls(items)
591
592 // If the user switches between sessions while the agent is working we want
593 // to make sure the animations are shown.
594 for _, item := range items {
595 if animatable, ok := item.(chat.Animatable); ok {
596 if cmd := animatable.StartAnimation(); cmd != nil {
597 cmds = append(cmds, cmd)
598 }
599 }
600 }
601
602 m.chat.SetMessages(items...)
603 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
604 cmds = append(cmds, cmd)
605 }
606 m.chat.SelectLast()
607 return tea.Batch(cmds...)
608}
609
610// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
611func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
612 for _, item := range items {
613 nestedContainer, ok := item.(chat.NestedToolContainer)
614 if !ok {
615 continue
616 }
617 toolItem, ok := item.(chat.ToolMessageItem)
618 if !ok {
619 continue
620 }
621
622 tc := toolItem.ToolCall()
623 messageID := toolItem.MessageID()
624
625 // Get the agent tool session ID.
626 agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
627
628 // Fetch nested messages.
629 nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
630 if err != nil || len(nestedMsgs) == 0 {
631 continue
632 }
633
634 // Build tool result map for nested messages.
635 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
636 for i := range nestedMsgs {
637 nestedMsgPtrs[i] = &nestedMsgs[i]
638 }
639 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
640
641 // Extract nested tool items.
642 var nestedTools []chat.ToolMessageItem
643 for _, nestedMsg := range nestedMsgPtrs {
644 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
645 for _, nestedItem := range nestedItems {
646 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
647 // Mark nested tools as simple (compact) rendering.
648 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
649 simplifiable.SetCompact(true)
650 }
651 nestedTools = append(nestedTools, nestedToolItem)
652 }
653 }
654 }
655
656 // Recursively load nested tool calls for any agent tools within.
657 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
658 for i, nt := range nestedTools {
659 nestedMessageItems[i] = nt
660 }
661 m.loadNestedToolCalls(nestedMessageItems)
662
663 // Set nested tools on the parent.
664 nestedContainer.SetNestedTools(nestedTools)
665 }
666}
667
668// appendSessionMessage appends a new message to the current session in the chat
669// if the message is a tool result it will update the corresponding tool call message
670func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
671 var cmds []tea.Cmd
672 existing := m.chat.MessageItem(msg.ID)
673 if existing != nil {
674 // message already exists, skip
675 return nil
676 }
677 switch msg.Role {
678 case message.User, message.Assistant:
679 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
680 for _, item := range items {
681 if animatable, ok := item.(chat.Animatable); ok {
682 if cmd := animatable.StartAnimation(); cmd != nil {
683 cmds = append(cmds, cmd)
684 }
685 }
686 }
687 m.chat.AppendMessages(items...)
688 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
689 cmds = append(cmds, cmd)
690 }
691 case message.Tool:
692 for _, tr := range msg.ToolResults() {
693 toolItem := m.chat.MessageItem(tr.ToolCallID)
694 if toolItem == nil {
695 // we should have an item!
696 continue
697 }
698 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
699 toolMsgItem.SetResult(&tr)
700 }
701 }
702 }
703 return tea.Batch(cmds...)
704}
705
706// updateSessionMessage updates an existing message in the current session in the chat
707// when an assistant message is updated it may include updated tool calls as well
708// that is why we need to handle creating/updating each tool call message too
709func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
710 var cmds []tea.Cmd
711 existingItem := m.chat.MessageItem(msg.ID)
712
713 if existingItem != nil {
714 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
715 assistantItem.SetMessage(&msg)
716 }
717 }
718
719 // if the message of the assistant does not have any response just tool calls we need to remove it
720 if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
721 m.chat.RemoveMessage(msg.ID)
722 }
723
724 var items []chat.MessageItem
725 for _, tc := range msg.ToolCalls() {
726 existingToolItem := m.chat.MessageItem(tc.ID)
727 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
728 existingToolCall := toolItem.ToolCall()
729 // only update if finished state changed or input changed
730 // to avoid clearing the cache
731 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
732 toolItem.SetToolCall(tc)
733 }
734 }
735 if existingToolItem == nil {
736 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
737 }
738 }
739
740 for _, item := range items {
741 if animatable, ok := item.(chat.Animatable); ok {
742 if cmd := animatable.StartAnimation(); cmd != nil {
743 cmds = append(cmds, cmd)
744 }
745 }
746 }
747 m.chat.AppendMessages(items...)
748 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
749 cmds = append(cmds, cmd)
750 }
751
752 return tea.Batch(cmds...)
753}
754
755// handleChildSessionMessage handles messages from child sessions (agent tools).
756func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
757 var cmds []tea.Cmd
758
759 // Only process messages with tool calls or results.
760 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
761 return nil
762 }
763
764 // Check if this is an agent tool session and parse it.
765 childSessionID := event.Payload.SessionID
766 _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
767 if !ok {
768 return nil
769 }
770
771 // Find the parent agent tool item.
772 var agentItem chat.NestedToolContainer
773 for i := 0; i < m.chat.Len(); i++ {
774 item := m.chat.MessageItem(toolCallID)
775 if item == nil {
776 continue
777 }
778 if agent, ok := item.(chat.NestedToolContainer); ok {
779 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
780 if toolMessageItem.ToolCall().ID == toolCallID {
781 // Verify this agent belongs to the correct parent message.
782 // We can't directly check parentMessageID on the item, so we trust the session parsing.
783 agentItem = agent
784 break
785 }
786 }
787 }
788 }
789
790 if agentItem == nil {
791 return nil
792 }
793
794 // Get existing nested tools.
795 nestedTools := agentItem.NestedTools()
796
797 // Update or create nested tool calls.
798 for _, tc := range event.Payload.ToolCalls() {
799 found := false
800 for _, existingTool := range nestedTools {
801 if existingTool.ToolCall().ID == tc.ID {
802 existingTool.SetToolCall(tc)
803 found = true
804 break
805 }
806 }
807 if !found {
808 // Create a new nested tool item.
809 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
810 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
811 simplifiable.SetCompact(true)
812 }
813 if animatable, ok := nestedItem.(chat.Animatable); ok {
814 if cmd := animatable.StartAnimation(); cmd != nil {
815 cmds = append(cmds, cmd)
816 }
817 }
818 nestedTools = append(nestedTools, nestedItem)
819 }
820 }
821
822 // Update nested tool results.
823 for _, tr := range event.Payload.ToolResults() {
824 for _, nestedTool := range nestedTools {
825 if nestedTool.ToolCall().ID == tr.ToolCallID {
826 nestedTool.SetResult(&tr)
827 break
828 }
829 }
830 }
831
832 // Update the agent item with the new nested tools.
833 agentItem.SetNestedTools(nestedTools)
834
835 // Update the chat so it updates the index map for animations to work as expected
836 m.chat.UpdateNestedToolIDs(toolCallID)
837
838 return tea.Batch(cmds...)
839}
840
841func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
842 var cmds []tea.Cmd
843 action := m.dialog.Update(msg)
844 if action == nil {
845 return tea.Batch(cmds...)
846 }
847
848 switch msg := action.(type) {
849 // Generic dialog messages
850 case dialog.ActionClose:
851 m.dialog.CloseFrontDialog()
852 if m.focus == uiFocusEditor {
853 cmds = append(cmds, m.textarea.Focus())
854 }
855 case dialog.ActionCmd:
856 if msg.Cmd != nil {
857 cmds = append(cmds, msg.Cmd)
858 }
859
860 // Session dialog messages
861 case dialog.ActionSelectSession:
862 m.dialog.CloseDialog(dialog.SessionsID)
863 cmds = append(cmds, m.loadSession(msg.Session.ID))
864
865 // Open dialog message
866 case dialog.ActionOpenDialog:
867 m.dialog.CloseDialog(dialog.CommandsID)
868 if cmd := m.openDialog(msg.DialogID); cmd != nil {
869 cmds = append(cmds, cmd)
870 }
871
872 // Command dialog messages
873 case dialog.ActionToggleYoloMode:
874 yolo := !m.com.App.Permissions.SkipRequests()
875 m.com.App.Permissions.SetSkipRequests(yolo)
876 m.setEditorPrompt(yolo)
877 m.dialog.CloseDialog(dialog.CommandsID)
878 case dialog.ActionNewSession:
879 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
880 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
881 break
882 }
883 m.newSession()
884 m.dialog.CloseDialog(dialog.CommandsID)
885 case dialog.ActionSummarize:
886 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
887 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
888 break
889 }
890 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
891 if err != nil {
892 cmds = append(cmds, uiutil.ReportError(err))
893 }
894 case dialog.ActionToggleHelp:
895 m.status.ToggleHelp()
896 m.dialog.CloseDialog(dialog.CommandsID)
897 case dialog.ActionToggleCompactMode:
898 cmds = append(cmds, m.toggleCompactMode())
899 m.dialog.CloseDialog(dialog.CommandsID)
900 case dialog.ActionQuit:
901 cmds = append(cmds, tea.Quit)
902 case dialog.ActionInitializeProject:
903 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
904 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
905 break
906 }
907 cmds = append(cmds, m.initializeProject())
908
909 case dialog.ActionSelectModel:
910 if m.com.App.AgentCoordinator.IsBusy() {
911 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
912 break
913 }
914
915 cfg := m.com.Config()
916 if cfg == nil {
917 cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
918 break
919 }
920
921 _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
922 if !isProviderConfigured {
923 m.dialog.CloseDialog(dialog.ModelsID)
924 if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
925 cmds = append(cmds, cmd)
926 }
927 break
928 }
929
930 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
931 cmds = append(cmds, uiutil.ReportError(err))
932 }
933
934 // XXX: Should this be in a separate goroutine?
935 go m.com.App.UpdateAgentModel(context.TODO())
936
937 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
938 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
939 m.dialog.CloseDialog(dialog.APIKeyInputID)
940 m.dialog.CloseDialog(dialog.ModelsID)
941 // TODO CHANGE
942 case dialog.ActionPermissionResponse:
943 m.dialog.CloseDialog(dialog.PermissionsID)
944 switch msg.Action {
945 case dialog.PermissionAllow:
946 m.com.App.Permissions.Grant(msg.Permission)
947 case dialog.PermissionAllowForSession:
948 m.com.App.Permissions.GrantPersistent(msg.Permission)
949 case dialog.PermissionDeny:
950 m.com.App.Permissions.Deny(msg.Permission)
951 }
952
953 case dialog.ActionRunCustomCommand:
954 if len(msg.Arguments) > 0 && msg.Args == nil {
955 m.dialog.CloseFrontDialog()
956 argsDialog := dialog.NewArguments(
957 m.com,
958 "Custom Command Arguments",
959 "",
960 msg.Arguments,
961 msg, // Pass the action as the result
962 )
963 m.dialog.OpenDialog(argsDialog)
964 break
965 }
966 content := msg.Content
967 if msg.Args != nil {
968 content = substituteArgs(content, msg.Args)
969 }
970 cmds = append(cmds, m.sendMessage(content))
971 m.dialog.CloseFrontDialog()
972 case dialog.ActionRunMCPPrompt:
973 if len(msg.Arguments) > 0 && msg.Args == nil {
974 m.dialog.CloseFrontDialog()
975 title := msg.Title
976 if title == "" {
977 title = "MCP Prompt Arguments"
978 }
979 argsDialog := dialog.NewArguments(
980 m.com,
981 title,
982 msg.Description,
983 msg.Arguments,
984 msg, // Pass the action as the result
985 )
986 m.dialog.OpenDialog(argsDialog)
987 break
988 }
989 cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
990 default:
991 cmds = append(cmds, uiutil.CmdHandler(msg))
992 }
993
994 return tea.Batch(cmds...)
995}
996
997// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
998func substituteArgs(content string, args map[string]string) string {
999 for name, value := range args {
1000 placeholder := "$" + name
1001 content = strings.ReplaceAll(content, placeholder, value)
1002 }
1003 return content
1004}
1005
1006// openAPIKeyInputDialog opens the API key input dialog.
1007func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1008 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1009 m.dialog.BringToFront(dialog.APIKeyInputID)
1010 return nil
1011 }
1012
1013 apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1014 if err != nil {
1015 return uiutil.ReportError(err)
1016 }
1017 m.dialog.OpenDialog(apiKeyInputDialog)
1018 return nil
1019}
1020
1021func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1022 var cmds []tea.Cmd
1023
1024 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1025 switch {
1026 case key.Matches(msg, m.keyMap.Help):
1027 m.status.ToggleHelp()
1028 m.updateLayoutAndSize()
1029 return true
1030 case key.Matches(msg, m.keyMap.Commands):
1031 if cmd := m.openCommandsDialog(); cmd != nil {
1032 cmds = append(cmds, cmd)
1033 }
1034 return true
1035 case key.Matches(msg, m.keyMap.Models):
1036 if cmd := m.openModelsDialog(); cmd != nil {
1037 cmds = append(cmds, cmd)
1038 }
1039 return true
1040 case key.Matches(msg, m.keyMap.Sessions):
1041 if cmd := m.openSessionsDialog(); cmd != nil {
1042 cmds = append(cmds, cmd)
1043 }
1044 return true
1045 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1046 m.detailsOpen = !m.detailsOpen
1047 m.updateLayoutAndSize()
1048 return true
1049 }
1050 return false
1051 }
1052
1053 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1054 // Always handle quit keys first
1055 if cmd := m.openQuitDialog(); cmd != nil {
1056 cmds = append(cmds, cmd)
1057 }
1058
1059 return tea.Batch(cmds...)
1060 }
1061
1062 // Route all messages to dialog if one is open.
1063 if m.dialog.HasDialogs() {
1064 return m.handleDialogMsg(msg)
1065 }
1066
1067 // Handle cancel key when agent is busy.
1068 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1069 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1070 if cmd := m.cancelAgent(); cmd != nil {
1071 cmds = append(cmds, cmd)
1072 }
1073 return tea.Batch(cmds...)
1074 }
1075 }
1076
1077 switch m.state {
1078 case uiConfigure:
1079 return tea.Batch(cmds...)
1080 case uiInitialize:
1081 cmds = append(cmds, m.updateInitializeView(msg)...)
1082 return tea.Batch(cmds...)
1083 case uiChat, uiLanding:
1084 switch m.focus {
1085 case uiFocusEditor:
1086 // Handle completions if open.
1087 if m.completionsOpen {
1088 if msg, ok := m.completions.Update(msg); ok {
1089 switch msg := msg.(type) {
1090 case completions.SelectionMsg:
1091 // Handle file completion selection.
1092 if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1093 cmds = append(cmds, m.insertFileCompletion(item.Path))
1094 }
1095 if !msg.Insert {
1096 m.closeCompletions()
1097 }
1098 case completions.ClosedMsg:
1099 m.completionsOpen = false
1100 }
1101 return tea.Batch(cmds...)
1102 }
1103 }
1104
1105 if ok := m.attachments.Update(msg); ok {
1106 return tea.Batch(cmds...)
1107 }
1108
1109 switch {
1110 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1111 value := m.textarea.Value()
1112 if before, ok := strings.CutSuffix(value, "\\"); ok {
1113 // If the last character is a backslash, remove it and add a newline.
1114 m.textarea.SetValue(before)
1115 break
1116 }
1117
1118 // Otherwise, send the message
1119 m.textarea.Reset()
1120
1121 value = strings.TrimSpace(value)
1122 if value == "exit" || value == "quit" {
1123 return m.openQuitDialog()
1124 }
1125
1126 attachments := m.attachments.List()
1127 m.attachments.Reset()
1128 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1129 return nil
1130 }
1131
1132 m.randomizePlaceholders()
1133
1134 return m.sendMessage(value, attachments...)
1135 case key.Matches(msg, m.keyMap.Chat.NewSession):
1136 if m.session == nil || m.session.ID == "" {
1137 break
1138 }
1139 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1140 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1141 break
1142 }
1143 m.newSession()
1144 case key.Matches(msg, m.keyMap.Tab):
1145 m.focus = uiFocusMain
1146 m.textarea.Blur()
1147 m.chat.Focus()
1148 m.chat.SetSelected(m.chat.Len() - 1)
1149 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1150 if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
1151 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1152 break
1153 }
1154 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1155 case key.Matches(msg, m.keyMap.Editor.Newline):
1156 m.textarea.InsertRune('\n')
1157 m.closeCompletions()
1158 default:
1159 if handleGlobalKeys(msg) {
1160 // Handle global keys first before passing to textarea.
1161 break
1162 }
1163
1164 // Check for @ trigger before passing to textarea.
1165 curValue := m.textarea.Value()
1166 curIdx := len(curValue)
1167
1168 // Trigger completions on @.
1169 if msg.String() == "@" && !m.completionsOpen {
1170 // Only show if beginning of prompt or after whitespace.
1171 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1172 m.completionsOpen = true
1173 m.completionsQuery = ""
1174 m.completionsStartIndex = curIdx
1175 m.completionsPositionStart = m.completionsPosition()
1176 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1177 cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1178 }
1179 }
1180
1181 // remove the details if they are open when user starts typing
1182 if m.detailsOpen {
1183 m.detailsOpen = false
1184 m.updateLayoutAndSize()
1185 }
1186
1187 ta, cmd := m.textarea.Update(msg)
1188 m.textarea = ta
1189 cmds = append(cmds, cmd)
1190
1191 // After updating textarea, check if we need to filter completions.
1192 // Skip filtering on the initial @ keystroke since items are loading async.
1193 if m.completionsOpen && msg.String() != "@" {
1194 newValue := m.textarea.Value()
1195 newIdx := len(newValue)
1196
1197 // Close completions if cursor moved before start.
1198 if newIdx <= m.completionsStartIndex {
1199 m.closeCompletions()
1200 } else if msg.String() == "space" {
1201 // Close on space.
1202 m.closeCompletions()
1203 } else {
1204 // Extract current word and filter.
1205 word := m.textareaWord()
1206 if strings.HasPrefix(word, "@") {
1207 m.completionsQuery = word[1:]
1208 m.completions.Filter(m.completionsQuery)
1209 } else if m.completionsOpen {
1210 m.closeCompletions()
1211 }
1212 }
1213 }
1214 }
1215 case uiFocusMain:
1216 switch {
1217 case key.Matches(msg, m.keyMap.Tab):
1218 m.focus = uiFocusEditor
1219 cmds = append(cmds, m.textarea.Focus())
1220 m.chat.Blur()
1221 case key.Matches(msg, m.keyMap.Chat.Expand):
1222 m.chat.ToggleExpandedSelectedItem()
1223 case key.Matches(msg, m.keyMap.Chat.Up):
1224 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1225 cmds = append(cmds, cmd)
1226 }
1227 if !m.chat.SelectedItemInView() {
1228 m.chat.SelectPrev()
1229 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1230 cmds = append(cmds, cmd)
1231 }
1232 }
1233 case key.Matches(msg, m.keyMap.Chat.Down):
1234 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1235 cmds = append(cmds, cmd)
1236 }
1237 if !m.chat.SelectedItemInView() {
1238 m.chat.SelectNext()
1239 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1240 cmds = append(cmds, cmd)
1241 }
1242 }
1243 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1244 m.chat.SelectPrev()
1245 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1246 cmds = append(cmds, cmd)
1247 }
1248 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1249 m.chat.SelectNext()
1250 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1251 cmds = append(cmds, cmd)
1252 }
1253 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1254 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1255 cmds = append(cmds, cmd)
1256 }
1257 m.chat.SelectFirstInView()
1258 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1259 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1260 cmds = append(cmds, cmd)
1261 }
1262 m.chat.SelectLastInView()
1263 case key.Matches(msg, m.keyMap.Chat.PageUp):
1264 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1265 cmds = append(cmds, cmd)
1266 }
1267 m.chat.SelectFirstInView()
1268 case key.Matches(msg, m.keyMap.Chat.PageDown):
1269 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1270 cmds = append(cmds, cmd)
1271 }
1272 m.chat.SelectLastInView()
1273 case key.Matches(msg, m.keyMap.Chat.Home):
1274 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1275 cmds = append(cmds, cmd)
1276 }
1277 m.chat.SelectFirst()
1278 case key.Matches(msg, m.keyMap.Chat.End):
1279 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1280 cmds = append(cmds, cmd)
1281 }
1282 m.chat.SelectLast()
1283 default:
1284 handleGlobalKeys(msg)
1285 }
1286 default:
1287 handleGlobalKeys(msg)
1288 }
1289 default:
1290 handleGlobalKeys(msg)
1291 }
1292
1293 return tea.Batch(cmds...)
1294}
1295
1296// Draw implements [uv.Drawable] and draws the UI model.
1297func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1298 layout := m.generateLayout(area.Dx(), area.Dy())
1299
1300 if m.layout != layout {
1301 m.layout = layout
1302 m.updateSize()
1303 }
1304
1305 // Clear the screen first
1306 screen.Clear(scr)
1307
1308 switch m.state {
1309 case uiConfigure:
1310 header := uv.NewStyledString(m.header)
1311 header.Draw(scr, layout.header)
1312
1313 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1314 Height(layout.main.Dy()).
1315 Background(lipgloss.ANSIColor(rand.Intn(256))).
1316 Render(" Configure ")
1317 main := uv.NewStyledString(mainView)
1318 main.Draw(scr, layout.main)
1319
1320 case uiInitialize:
1321 header := uv.NewStyledString(m.header)
1322 header.Draw(scr, layout.header)
1323
1324 main := uv.NewStyledString(m.initializeView())
1325 main.Draw(scr, layout.main)
1326
1327 case uiLanding:
1328 header := uv.NewStyledString(m.header)
1329 header.Draw(scr, layout.header)
1330 main := uv.NewStyledString(m.landingView())
1331 main.Draw(scr, layout.main)
1332
1333 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1334 editor.Draw(scr, layout.editor)
1335
1336 case uiChat:
1337 if m.isCompact {
1338 header := uv.NewStyledString(m.header)
1339 header.Draw(scr, layout.header)
1340 } else {
1341 m.drawSidebar(scr, layout.sidebar)
1342 }
1343
1344 m.chat.Draw(scr, layout.main)
1345
1346 editorWidth := scr.Bounds().Dx()
1347 if !m.isCompact {
1348 editorWidth -= layout.sidebar.Dx()
1349 }
1350 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1351 editor.Draw(scr, layout.editor)
1352
1353 // Draw details overlay in compact mode when open
1354 if m.isCompact && m.detailsOpen {
1355 m.drawSessionDetails(scr, layout.sessionDetails)
1356 }
1357 }
1358
1359 // Add status and help layer
1360 m.status.Draw(scr, layout.status)
1361
1362 // Draw completions popup if open
1363 if m.completionsOpen && m.completions.HasItems() {
1364 w, h := m.completions.Size()
1365 x := m.completionsPositionStart.X
1366 y := m.completionsPositionStart.Y - h
1367
1368 screenW := area.Dx()
1369 if x+w > screenW {
1370 x = screenW - w
1371 }
1372 x = max(0, x)
1373 y = max(0, y)
1374
1375 completionsView := uv.NewStyledString(m.completions.Render())
1376 completionsView.Draw(scr, image.Rectangle{
1377 Min: image.Pt(x, y),
1378 Max: image.Pt(x+w, y+h),
1379 })
1380 }
1381
1382 // Debugging rendering (visually see when the tui rerenders)
1383 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1384 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1385 debug := uv.NewStyledString(debugView.String())
1386 debug.Draw(scr, image.Rectangle{
1387 Min: image.Pt(4, 1),
1388 Max: image.Pt(8, 3),
1389 })
1390 }
1391
1392 // This needs to come last to overlay on top of everything. We always pass
1393 // the full screen bounds because the dialogs will position themselves
1394 // accordingly.
1395 if m.dialog.HasDialogs() {
1396 return m.dialog.Draw(scr, scr.Bounds())
1397 }
1398
1399 switch m.focus {
1400 case uiFocusEditor:
1401 if m.layout.editor.Dy() <= 0 {
1402 // Don't show cursor if editor is not visible
1403 return nil
1404 }
1405 if m.detailsOpen && m.isCompact {
1406 // Don't show cursor if details overlay is open
1407 return nil
1408 }
1409
1410 if m.textarea.Focused() {
1411 cur := m.textarea.Cursor()
1412 cur.X++ // Adjust for app margins
1413 cur.Y += m.layout.editor.Min.Y
1414 // Offset for attachment row if present.
1415 if len(m.attachments.List()) > 0 {
1416 cur.Y++
1417 }
1418 return cur
1419 }
1420 }
1421 return nil
1422}
1423
1424// View renders the UI model's view.
1425func (m *UI) View() tea.View {
1426 var v tea.View
1427 v.AltScreen = true
1428 v.BackgroundColor = m.com.Styles.Background
1429 v.MouseMode = tea.MouseModeCellMotion
1430
1431 canvas := uv.NewScreenBuffer(m.width, m.height)
1432 v.Cursor = m.Draw(canvas, canvas.Bounds())
1433
1434 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1435 contentLines := strings.Split(content, "\n")
1436 for i, line := range contentLines {
1437 // Trim trailing spaces for concise rendering
1438 contentLines[i] = strings.TrimRight(line, " ")
1439 }
1440
1441 content = strings.Join(contentLines, "\n")
1442
1443 v.Content = content
1444 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1445 // HACK: use a random percentage to prevent ghostty from hiding it
1446 // after a timeout.
1447 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1448 }
1449
1450 return v
1451}
1452
1453// ShortHelp implements [help.KeyMap].
1454func (m *UI) ShortHelp() []key.Binding {
1455 var binds []key.Binding
1456 k := &m.keyMap
1457 tab := k.Tab
1458 commands := k.Commands
1459 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1460 commands.SetHelp("/ or ctrl+p", "commands")
1461 }
1462
1463 switch m.state {
1464 case uiInitialize:
1465 binds = append(binds, k.Quit)
1466 case uiChat:
1467 // Show cancel binding if agent is busy.
1468 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1469 cancelBinding := k.Chat.Cancel
1470 if m.isCanceling {
1471 cancelBinding.SetHelp("esc", "press again to cancel")
1472 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1473 cancelBinding.SetHelp("esc", "clear queue")
1474 }
1475 binds = append(binds, cancelBinding)
1476 }
1477
1478 if m.focus == uiFocusEditor {
1479 tab.SetHelp("tab", "focus chat")
1480 } else {
1481 tab.SetHelp("tab", "focus editor")
1482 }
1483
1484 binds = append(binds,
1485 tab,
1486 commands,
1487 k.Models,
1488 )
1489
1490 switch m.focus {
1491 case uiFocusEditor:
1492 binds = append(binds,
1493 k.Editor.Newline,
1494 )
1495 case uiFocusMain:
1496 binds = append(binds,
1497 k.Chat.UpDown,
1498 k.Chat.UpDownOneItem,
1499 k.Chat.PageUp,
1500 k.Chat.PageDown,
1501 k.Chat.Copy,
1502 )
1503 }
1504 default:
1505 // TODO: other states
1506 // if m.session == nil {
1507 // no session selected
1508 binds = append(binds,
1509 commands,
1510 k.Models,
1511 k.Editor.Newline,
1512 )
1513 }
1514
1515 binds = append(binds,
1516 k.Quit,
1517 k.Help,
1518 )
1519
1520 return binds
1521}
1522
1523// FullHelp implements [help.KeyMap].
1524func (m *UI) FullHelp() [][]key.Binding {
1525 var binds [][]key.Binding
1526 k := &m.keyMap
1527 help := k.Help
1528 help.SetHelp("ctrl+g", "less")
1529 hasAttachments := len(m.attachments.List()) > 0
1530 hasSession := m.session != nil && m.session.ID != ""
1531 commands := k.Commands
1532 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1533 commands.SetHelp("/ or ctrl+p", "commands")
1534 }
1535
1536 switch m.state {
1537 case uiInitialize:
1538 binds = append(binds,
1539 []key.Binding{
1540 k.Quit,
1541 })
1542 case uiChat:
1543 // Show cancel binding if agent is busy.
1544 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1545 cancelBinding := k.Chat.Cancel
1546 if m.isCanceling {
1547 cancelBinding.SetHelp("esc", "press again to cancel")
1548 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1549 cancelBinding.SetHelp("esc", "clear queue")
1550 }
1551 binds = append(binds, []key.Binding{cancelBinding})
1552 }
1553
1554 mainBinds := []key.Binding{}
1555 tab := k.Tab
1556 if m.focus == uiFocusEditor {
1557 tab.SetHelp("tab", "focus chat")
1558 } else {
1559 tab.SetHelp("tab", "focus editor")
1560 }
1561
1562 mainBinds = append(mainBinds,
1563 tab,
1564 commands,
1565 k.Models,
1566 k.Sessions,
1567 )
1568 if hasSession {
1569 mainBinds = append(mainBinds, k.Chat.NewSession)
1570 }
1571
1572 binds = append(binds, mainBinds)
1573
1574 switch m.focus {
1575 case uiFocusEditor:
1576 binds = append(binds,
1577 []key.Binding{
1578 k.Editor.Newline,
1579 k.Editor.AddImage,
1580 k.Editor.MentionFile,
1581 k.Editor.OpenEditor,
1582 },
1583 )
1584 if hasAttachments {
1585 binds = append(binds,
1586 []key.Binding{
1587 k.Editor.AttachmentDeleteMode,
1588 k.Editor.DeleteAllAttachments,
1589 k.Editor.Escape,
1590 },
1591 )
1592 }
1593 case uiFocusMain:
1594 binds = append(binds,
1595 []key.Binding{
1596 k.Chat.UpDown,
1597 k.Chat.UpDownOneItem,
1598 k.Chat.PageUp,
1599 k.Chat.PageDown,
1600 },
1601 []key.Binding{
1602 k.Chat.HalfPageUp,
1603 k.Chat.HalfPageDown,
1604 k.Chat.Home,
1605 k.Chat.End,
1606 },
1607 []key.Binding{
1608 k.Chat.Copy,
1609 k.Chat.ClearHighlight,
1610 },
1611 )
1612 }
1613 default:
1614 if m.session == nil {
1615 // no session selected
1616 binds = append(binds,
1617 []key.Binding{
1618 commands,
1619 k.Models,
1620 k.Sessions,
1621 },
1622 []key.Binding{
1623 k.Editor.Newline,
1624 k.Editor.AddImage,
1625 k.Editor.MentionFile,
1626 k.Editor.OpenEditor,
1627 },
1628 )
1629 if hasAttachments {
1630 binds = append(binds,
1631 []key.Binding{
1632 k.Editor.AttachmentDeleteMode,
1633 k.Editor.DeleteAllAttachments,
1634 k.Editor.Escape,
1635 },
1636 )
1637 }
1638 binds = append(binds,
1639 []key.Binding{
1640 help,
1641 },
1642 )
1643 }
1644 }
1645
1646 binds = append(binds,
1647 []key.Binding{
1648 help,
1649 k.Quit,
1650 },
1651 )
1652
1653 return binds
1654}
1655
1656// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1657func (m *UI) toggleCompactMode() tea.Cmd {
1658 m.forceCompactMode = !m.forceCompactMode
1659
1660 err := m.com.Config().SetCompactMode(m.forceCompactMode)
1661 if err != nil {
1662 return uiutil.ReportError(err)
1663 }
1664
1665 m.handleCompactMode(m.width, m.height)
1666 m.updateLayoutAndSize()
1667
1668 return nil
1669}
1670
1671// handleCompactMode updates the UI state based on window size and compact mode setting.
1672func (m *UI) handleCompactMode(newWidth, newHeight int) {
1673 if m.state == uiChat {
1674 if m.forceCompactMode {
1675 m.isCompact = true
1676 return
1677 }
1678 if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1679 m.isCompact = true
1680 } else {
1681 m.isCompact = false
1682 }
1683 }
1684}
1685
1686// updateLayoutAndSize updates the layout and sizes of UI components.
1687func (m *UI) updateLayoutAndSize() {
1688 m.layout = m.generateLayout(m.width, m.height)
1689 m.updateSize()
1690}
1691
1692// updateSize updates the sizes of UI components based on the current layout.
1693func (m *UI) updateSize() {
1694 // Set status width
1695 m.status.SetWidth(m.layout.status.Dx())
1696
1697 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1698 m.textarea.SetWidth(m.layout.editor.Dx())
1699 m.textarea.SetHeight(m.layout.editor.Dy())
1700
1701 // Handle different app states
1702 switch m.state {
1703 case uiConfigure, uiInitialize, uiLanding:
1704 m.renderHeader(false, m.layout.header.Dx())
1705
1706 case uiChat:
1707 if m.isCompact {
1708 m.renderHeader(true, m.layout.header.Dx())
1709 } else {
1710 m.renderSidebarLogo(m.layout.sidebar.Dx())
1711 }
1712 }
1713}
1714
1715// generateLayout calculates the layout rectangles for all UI components based
1716// on the current UI state and terminal dimensions.
1717func (m *UI) generateLayout(w, h int) layout {
1718 // The screen area we're working with
1719 area := image.Rect(0, 0, w, h)
1720
1721 // The help height
1722 helpHeight := 1
1723 // The editor height
1724 editorHeight := 5
1725 // The sidebar width
1726 sidebarWidth := 30
1727 // The header height
1728 const landingHeaderHeight = 4
1729
1730 var helpKeyMap help.KeyMap = m
1731 if m.status.ShowingAll() {
1732 for _, row := range helpKeyMap.FullHelp() {
1733 helpHeight = max(helpHeight, len(row))
1734 }
1735 }
1736
1737 // Add app margins
1738 appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
1739 appRect.Min.Y += 1
1740 appRect.Max.Y -= 1
1741 helpRect.Min.Y -= 1
1742 appRect.Min.X += 1
1743 appRect.Max.X -= 1
1744
1745 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1746 // extra padding on left and right for these states
1747 appRect.Min.X += 1
1748 appRect.Max.X -= 1
1749 }
1750
1751 layout := layout{
1752 area: area,
1753 status: helpRect,
1754 }
1755
1756 // Handle different app states
1757 switch m.state {
1758 case uiConfigure, uiInitialize:
1759 // Layout
1760 //
1761 // header
1762 // ------
1763 // main
1764 // ------
1765 // help
1766
1767 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1768 layout.header = headerRect
1769 layout.main = mainRect
1770
1771 case uiLanding:
1772 // Layout
1773 //
1774 // header
1775 // ------
1776 // main
1777 // ------
1778 // editor
1779 // ------
1780 // help
1781 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1782 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1783 // Remove extra padding from editor (but keep it for header and main)
1784 editorRect.Min.X -= 1
1785 editorRect.Max.X += 1
1786 layout.header = headerRect
1787 layout.main = mainRect
1788 layout.editor = editorRect
1789
1790 case uiChat:
1791 if m.isCompact {
1792 // Layout
1793 //
1794 // compact-header
1795 // ------
1796 // main
1797 // ------
1798 // editor
1799 // ------
1800 // help
1801 const compactHeaderHeight = 1
1802 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
1803 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
1804 sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
1805 layout.sessionDetails = sessionDetailsArea
1806 layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
1807 // Add one line gap between header and main content
1808 mainRect.Min.Y += 1
1809 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1810 mainRect.Max.X -= 1 // Add padding right
1811 // Add bottom margin to main
1812 mainRect.Max.Y -= 1
1813 layout.header = headerRect
1814 layout.main = mainRect
1815 layout.editor = editorRect
1816 } else {
1817 // Layout
1818 //
1819 // ------|---
1820 // main |
1821 // ------| side
1822 // editor|
1823 // ----------
1824 // help
1825
1826 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1827 // Add padding left
1828 sideRect.Min.X += 1
1829 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1830 mainRect.Max.X -= 1 // Add padding right
1831 // Add bottom margin to main
1832 mainRect.Max.Y -= 1
1833 layout.sidebar = sideRect
1834 layout.main = mainRect
1835 layout.editor = editorRect
1836 }
1837 }
1838
1839 if !layout.editor.Empty() {
1840 // Add editor margins 1 top and bottom
1841 layout.editor.Min.Y += 1
1842 layout.editor.Max.Y -= 1
1843 }
1844
1845 return layout
1846}
1847
1848// layout defines the positioning of UI elements.
1849type layout struct {
1850 // area is the overall available area.
1851 area uv.Rectangle
1852
1853 // header is the header shown in special cases
1854 // e.x when the sidebar is collapsed
1855 // or when in the landing page
1856 // or in init/config
1857 header uv.Rectangle
1858
1859 // main is the area for the main pane. (e.x chat, configure, landing)
1860 main uv.Rectangle
1861
1862 // editor is the area for the editor pane.
1863 editor uv.Rectangle
1864
1865 // sidebar is the area for the sidebar.
1866 sidebar uv.Rectangle
1867
1868 // status is the area for the status view.
1869 status uv.Rectangle
1870
1871 // session details is the area for the session details overlay in compact mode.
1872 sessionDetails uv.Rectangle
1873}
1874
1875func (m *UI) openEditor(value string) tea.Cmd {
1876 tmpfile, err := os.CreateTemp("", "msg_*.md")
1877 if err != nil {
1878 return uiutil.ReportError(err)
1879 }
1880 defer tmpfile.Close() //nolint:errcheck
1881 if _, err := tmpfile.WriteString(value); err != nil {
1882 return uiutil.ReportError(err)
1883 }
1884 cmd, err := editor.Command(
1885 "crush",
1886 tmpfile.Name(),
1887 editor.AtPosition(
1888 m.textarea.Line()+1,
1889 m.textarea.Column()+1,
1890 ),
1891 )
1892 if err != nil {
1893 return uiutil.ReportError(err)
1894 }
1895 return tea.ExecProcess(cmd, func(err error) tea.Msg {
1896 if err != nil {
1897 return uiutil.ReportError(err)
1898 }
1899 content, err := os.ReadFile(tmpfile.Name())
1900 if err != nil {
1901 return uiutil.ReportError(err)
1902 }
1903 if len(content) == 0 {
1904 return uiutil.ReportWarn("Message is empty")
1905 }
1906 os.Remove(tmpfile.Name())
1907 return openEditorMsg{
1908 Text: strings.TrimSpace(string(content)),
1909 }
1910 })
1911}
1912
1913// setEditorPrompt configures the textarea prompt function based on whether
1914// yolo mode is enabled.
1915func (m *UI) setEditorPrompt(yolo bool) {
1916 if yolo {
1917 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1918 return
1919 }
1920 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1921}
1922
1923// normalPromptFunc returns the normal editor prompt style (" > " on first
1924// line, "::: " on subsequent lines).
1925func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1926 t := m.com.Styles
1927 if info.LineNumber == 0 {
1928 if info.Focused {
1929 return " > "
1930 }
1931 return "::: "
1932 }
1933 if info.Focused {
1934 return t.EditorPromptNormalFocused.Render()
1935 }
1936 return t.EditorPromptNormalBlurred.Render()
1937}
1938
1939// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1940// and colored dots.
1941func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1942 t := m.com.Styles
1943 if info.LineNumber == 0 {
1944 if info.Focused {
1945 return t.EditorPromptYoloIconFocused.Render()
1946 } else {
1947 return t.EditorPromptYoloIconBlurred.Render()
1948 }
1949 }
1950 if info.Focused {
1951 return t.EditorPromptYoloDotsFocused.Render()
1952 }
1953 return t.EditorPromptYoloDotsBlurred.Render()
1954}
1955
1956// closeCompletions closes the completions popup and resets state.
1957func (m *UI) closeCompletions() {
1958 m.completionsOpen = false
1959 m.completionsQuery = ""
1960 m.completionsStartIndex = 0
1961 m.completions.Close()
1962}
1963
1964// insertFileCompletion inserts the selected file path into the textarea,
1965// replacing the @query, and adds the file as an attachment.
1966func (m *UI) insertFileCompletion(path string) tea.Cmd {
1967 value := m.textarea.Value()
1968 word := m.textareaWord()
1969
1970 // Find the @ and query to replace.
1971 if m.completionsStartIndex > len(value) {
1972 return nil
1973 }
1974
1975 // Build the new value: everything before @, the path, everything after query.
1976 endIdx := min(m.completionsStartIndex+len(word), len(value))
1977
1978 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1979 m.textarea.SetValue(newValue)
1980 m.textarea.MoveToEnd()
1981 m.textarea.InsertRune(' ')
1982
1983 return func() tea.Msg {
1984 absPath, _ := filepath.Abs(path)
1985 // Skip attachment if file was already read and hasn't been modified.
1986 lastRead := filetracker.LastReadTime(absPath)
1987 if !lastRead.IsZero() {
1988 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
1989 return nil
1990 }
1991 }
1992
1993 // Add file as attachment.
1994 content, err := os.ReadFile(path)
1995 if err != nil {
1996 // If it fails, let the LLM handle it later.
1997 return nil
1998 }
1999 filetracker.RecordRead(absPath)
2000
2001 return message.Attachment{
2002 FilePath: path,
2003 FileName: filepath.Base(path),
2004 MimeType: mimeOf(content),
2005 Content: content,
2006 }
2007 }
2008}
2009
2010// completionsPosition returns the X and Y position for the completions popup.
2011func (m *UI) completionsPosition() image.Point {
2012 cur := m.textarea.Cursor()
2013 if cur == nil {
2014 return image.Point{
2015 X: m.layout.editor.Min.X,
2016 Y: m.layout.editor.Min.Y,
2017 }
2018 }
2019 return image.Point{
2020 X: cur.X + m.layout.editor.Min.X,
2021 Y: m.layout.editor.Min.Y + cur.Y,
2022 }
2023}
2024
2025// textareaWord returns the current word at the cursor position.
2026func (m *UI) textareaWord() string {
2027 return m.textarea.Word()
2028}
2029
2030// isWhitespace returns true if the byte is a whitespace character.
2031func isWhitespace(b byte) bool {
2032 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2033}
2034
2035// mimeOf detects the MIME type of the given content.
2036func mimeOf(content []byte) string {
2037 mimeBufferSize := min(512, len(content))
2038 return http.DetectContentType(content[:mimeBufferSize])
2039}
2040
2041var readyPlaceholders = [...]string{
2042 "Ready!",
2043 "Ready...",
2044 "Ready?",
2045 "Ready for instructions",
2046}
2047
2048var workingPlaceholders = [...]string{
2049 "Working!",
2050 "Working...",
2051 "Brrrrr...",
2052 "Prrrrrrrr...",
2053 "Processing...",
2054 "Thinking...",
2055}
2056
2057// randomizePlaceholders selects random placeholder text for the textarea's
2058// ready and working states.
2059func (m *UI) randomizePlaceholders() {
2060 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2061 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2062}
2063
2064// renderEditorView renders the editor view with attachments if any.
2065func (m *UI) renderEditorView(width int) string {
2066 if len(m.attachments.List()) == 0 {
2067 return m.textarea.View()
2068 }
2069 return lipgloss.JoinVertical(
2070 lipgloss.Top,
2071 m.attachments.Render(width),
2072 m.textarea.View(),
2073 )
2074}
2075
2076// renderHeader renders and caches the header logo at the specified width.
2077func (m *UI) renderHeader(compact bool, width int) {
2078 if compact && m.session != nil && m.com.App != nil {
2079 m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2080 } else {
2081 m.header = renderLogo(m.com.Styles, compact, width)
2082 }
2083}
2084
2085// renderSidebarLogo renders and caches the sidebar logo at the specified
2086// width.
2087func (m *UI) renderSidebarLogo(width int) {
2088 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2089}
2090
2091// sendMessage sends a message with the given content and attachments.
2092func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2093 if m.com.App.AgentCoordinator == nil {
2094 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2095 }
2096
2097 var cmds []tea.Cmd
2098 if m.session == nil || m.session.ID == "" {
2099 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2100 if err != nil {
2101 return uiutil.ReportError(err)
2102 }
2103 m.state = uiChat
2104 if m.forceCompactMode {
2105 m.isCompact = true
2106 }
2107 if newSession.ID != "" {
2108 m.session = &newSession
2109 cmds = append(cmds, m.loadSession(newSession.ID))
2110 }
2111 }
2112
2113 // Capture session ID to avoid race with main goroutine updating m.session.
2114 sessionID := m.session.ID
2115 cmds = append(cmds, func() tea.Msg {
2116 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2117 if err != nil {
2118 isCancelErr := errors.Is(err, context.Canceled)
2119 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2120 if isCancelErr || isPermissionErr {
2121 return nil
2122 }
2123 return uiutil.InfoMsg{
2124 Type: uiutil.InfoTypeError,
2125 Msg: err.Error(),
2126 }
2127 }
2128 return nil
2129 })
2130 return tea.Batch(cmds...)
2131}
2132
2133const cancelTimerDuration = 2 * time.Second
2134
2135// cancelTimerCmd creates a command that expires the cancel timer.
2136func cancelTimerCmd() tea.Cmd {
2137 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2138 return cancelTimerExpiredMsg{}
2139 })
2140}
2141
2142// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2143// and starts a timer. The second press (before the timer expires) actually
2144// cancels the agent.
2145func (m *UI) cancelAgent() tea.Cmd {
2146 if m.session == nil || m.session.ID == "" {
2147 return nil
2148 }
2149
2150 coordinator := m.com.App.AgentCoordinator
2151 if coordinator == nil {
2152 return nil
2153 }
2154
2155 if m.isCanceling {
2156 // Second escape press - actually cancel the agent.
2157 m.isCanceling = false
2158 coordinator.Cancel(m.session.ID)
2159 return nil
2160 }
2161
2162 // Check if there are queued prompts - if so, clear the queue.
2163 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2164 coordinator.ClearQueue(m.session.ID)
2165 return nil
2166 }
2167
2168 // First escape press - set canceling state and start timer.
2169 m.isCanceling = true
2170 return cancelTimerCmd()
2171}
2172
2173// openDialog opens a dialog by its ID.
2174func (m *UI) openDialog(id string) tea.Cmd {
2175 var cmds []tea.Cmd
2176 switch id {
2177 case dialog.SessionsID:
2178 if cmd := m.openSessionsDialog(); cmd != nil {
2179 cmds = append(cmds, cmd)
2180 }
2181 case dialog.ModelsID:
2182 if cmd := m.openModelsDialog(); cmd != nil {
2183 cmds = append(cmds, cmd)
2184 }
2185 case dialog.CommandsID:
2186 if cmd := m.openCommandsDialog(); cmd != nil {
2187 cmds = append(cmds, cmd)
2188 }
2189 case dialog.QuitID:
2190 if cmd := m.openQuitDialog(); cmd != nil {
2191 cmds = append(cmds, cmd)
2192 }
2193 default:
2194 // Unknown dialog
2195 break
2196 }
2197 return tea.Batch(cmds...)
2198}
2199
2200// openQuitDialog opens the quit confirmation dialog.
2201func (m *UI) openQuitDialog() tea.Cmd {
2202 if m.dialog.ContainsDialog(dialog.QuitID) {
2203 // Bring to front
2204 m.dialog.BringToFront(dialog.QuitID)
2205 return nil
2206 }
2207
2208 quitDialog := dialog.NewQuit(m.com)
2209 m.dialog.OpenDialog(quitDialog)
2210 return nil
2211}
2212
2213// openModelsDialog opens the models dialog.
2214func (m *UI) openModelsDialog() tea.Cmd {
2215 if m.dialog.ContainsDialog(dialog.ModelsID) {
2216 // Bring to front
2217 m.dialog.BringToFront(dialog.ModelsID)
2218 return nil
2219 }
2220
2221 modelsDialog, err := dialog.NewModels(m.com)
2222 if err != nil {
2223 return uiutil.ReportError(err)
2224 }
2225
2226 m.dialog.OpenDialog(modelsDialog)
2227
2228 return nil
2229}
2230
2231// openCommandsDialog opens the commands dialog.
2232func (m *UI) openCommandsDialog() tea.Cmd {
2233 if m.dialog.ContainsDialog(dialog.CommandsID) {
2234 // Bring to front
2235 m.dialog.BringToFront(dialog.CommandsID)
2236 return nil
2237 }
2238
2239 sessionID := ""
2240 if m.session != nil {
2241 sessionID = m.session.ID
2242 }
2243
2244 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2245 if err != nil {
2246 return uiutil.ReportError(err)
2247 }
2248
2249 m.dialog.OpenDialog(commands)
2250
2251 return nil
2252}
2253
2254// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2255// it brings it to the front. Otherwise, it will list all the sessions and open
2256// the dialog.
2257func (m *UI) openSessionsDialog() tea.Cmd {
2258 if m.dialog.ContainsDialog(dialog.SessionsID) {
2259 // Bring to front
2260 m.dialog.BringToFront(dialog.SessionsID)
2261 return nil
2262 }
2263
2264 selectedSessionID := ""
2265 if m.session != nil {
2266 selectedSessionID = m.session.ID
2267 }
2268
2269 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2270 if err != nil {
2271 return uiutil.ReportError(err)
2272 }
2273
2274 m.dialog.OpenDialog(dialog)
2275
2276 return nil
2277}
2278
2279// openPermissionsDialog opens the permissions dialog for a permission request.
2280func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2281 // Close any existing permissions dialog first.
2282 m.dialog.CloseDialog(dialog.PermissionsID)
2283
2284 // Get diff mode from config.
2285 var opts []dialog.PermissionsOption
2286 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2287 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2288 }
2289
2290 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2291 m.dialog.OpenDialog(permDialog)
2292 return nil
2293}
2294
2295// handlePermissionNotification updates tool items when permission state changes.
2296func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2297 toolItem := m.chat.MessageItem(notification.ToolCallID)
2298 if toolItem == nil {
2299 return
2300 }
2301
2302 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2303 if notification.Granted {
2304 permItem.SetStatus(chat.ToolStatusRunning)
2305 } else {
2306 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2307 }
2308 }
2309}
2310
2311// newSession clears the current session state and prepares for a new session.
2312// The actual session creation happens when the user sends their first message.
2313func (m *UI) newSession() {
2314 if m.session == nil || m.session.ID == "" {
2315 return
2316 }
2317
2318 m.session = nil
2319 m.sessionFiles = nil
2320 m.state = uiLanding
2321 m.focus = uiFocusEditor
2322 m.textarea.Focus()
2323 m.chat.Blur()
2324 m.chat.ClearMessages()
2325}
2326
2327// handlePasteMsg handles a paste message.
2328func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2329 if m.dialog.HasDialogs() {
2330 return m.handleDialogMsg(msg)
2331 }
2332
2333 if m.focus != uiFocusEditor {
2334 return nil
2335 }
2336
2337 // If pasted text has more than 2 newlines, treat it as a file attachment.
2338 if strings.Count(msg.Content, "\n") > 2 {
2339 return func() tea.Msg {
2340 content := []byte(msg.Content)
2341 if int64(len(content)) > maxAttachmentSize {
2342 return uiutil.ReportWarn("Paste is too big (>5mb)")
2343 }
2344 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2345 mimeBufferSize := min(512, len(content))
2346 mimeType := http.DetectContentType(content[:mimeBufferSize])
2347 return message.Attachment{
2348 FileName: name,
2349 FilePath: name,
2350 MimeType: mimeType,
2351 Content: content,
2352 }
2353 }
2354 }
2355
2356 var cmd tea.Cmd
2357 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2358 // Try to get an image.
2359 path, err := filepath.Abs(strings.TrimSpace(path))
2360 if err != nil {
2361 m.textarea, cmd = m.textarea.Update(msg)
2362 return cmd
2363 }
2364
2365 // Check if file has an allowed image extension.
2366 isAllowedType := false
2367 lowerPath := strings.ToLower(path)
2368 for _, ext := range allowedImageTypes {
2369 if strings.HasSuffix(lowerPath, ext) {
2370 isAllowedType = true
2371 break
2372 }
2373 }
2374 if !isAllowedType {
2375 m.textarea, cmd = m.textarea.Update(msg)
2376 return cmd
2377 }
2378
2379 return func() tea.Msg {
2380 fileInfo, err := os.Stat(path)
2381 if err != nil {
2382 return uiutil.ReportError(err)
2383 }
2384 if fileInfo.Size() > maxAttachmentSize {
2385 return uiutil.ReportWarn("File is too big (>5mb)")
2386 }
2387
2388 content, err := os.ReadFile(path)
2389 if err != nil {
2390 return uiutil.ReportError(err)
2391 }
2392
2393 mimeBufferSize := min(512, len(content))
2394 mimeType := http.DetectContentType(content[:mimeBufferSize])
2395 fileName := filepath.Base(path)
2396 return message.Attachment{
2397 FilePath: path,
2398 FileName: fileName,
2399 MimeType: mimeType,
2400 Content: content,
2401 }
2402 }
2403}
2404
2405var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2406
2407func (m *UI) pasteIdx() int {
2408 result := 0
2409 for _, at := range m.attachments.List() {
2410 found := pasteRE.FindStringSubmatch(at.FileName)
2411 if len(found) == 0 {
2412 continue
2413 }
2414 idx, err := strconv.Atoi(found[1])
2415 if err == nil {
2416 result = max(result, idx)
2417 }
2418 }
2419 return result + 1
2420}
2421
2422// drawSessionDetails draws the session details in compact mode.
2423func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2424 if m.session == nil {
2425 return
2426 }
2427
2428 s := m.com.Styles
2429
2430 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2431 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2432
2433 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2434 blocks := []string{
2435 title,
2436 "",
2437 m.modelInfo(width),
2438 "",
2439 }
2440
2441 detailsHeader := lipgloss.JoinVertical(
2442 lipgloss.Left,
2443 blocks...,
2444 )
2445
2446 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2447
2448 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2449
2450 const maxSectionWidth = 50
2451 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2452 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2453
2454 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2455 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2456 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2457 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2458 uv.NewStyledString(
2459 s.CompactDetails.View.
2460 Width(area.Dx()).
2461 Render(
2462 lipgloss.JoinVertical(
2463 lipgloss.Left,
2464 detailsHeader,
2465 sections,
2466 version,
2467 ),
2468 ),
2469 ).Draw(scr, area)
2470}
2471
2472func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2473 load := func() tea.Msg {
2474 prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2475 if err != nil {
2476 // TODO: make this better
2477 return uiutil.ReportError(err)()
2478 }
2479
2480 if prompt == "" {
2481 return nil
2482 }
2483 return sendMessageMsg{
2484 Content: prompt,
2485 }
2486 }
2487
2488 var cmds []tea.Cmd
2489 if cmd := m.dialog.StartLoading(); cmd != nil {
2490 cmds = append(cmds, cmd)
2491 }
2492 cmds = append(cmds, load, func() tea.Msg {
2493 return closeDialogMsg{}
2494 })
2495
2496 return tea.Sequence(cmds...)
2497}
2498
2499// renderLogo renders the Crush logo with the given styles and dimensions.
2500func renderLogo(t *styles.Styles, compact bool, width int) string {
2501 return logo.Render(version.Version, compact, logo.Opts{
2502 FieldColor: t.LogoFieldColor,
2503 TitleColorA: t.LogoTitleColorA,
2504 TitleColorB: t.LogoTitleColorB,
2505 CharmColor: t.LogoCharmColor,
2506 VersionColor: t.LogoVersionColor,
2507 Width: width,
2508 })
2509}