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