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 _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
935 if !isProviderConfigured {
936 m.dialog.CloseDialog(dialog.ModelsID)
937 if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
938 cmds = append(cmds, cmd)
939 }
940 break
941 }
942
943 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
944 cmds = append(cmds, uiutil.ReportError(err))
945 }
946
947 // XXX: Should this be in a separate goroutine?
948 go m.com.App.UpdateAgentModel(context.TODO())
949
950 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
951 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
952 m.dialog.CloseDialog(dialog.APIKeyInputID)
953 m.dialog.CloseDialog(dialog.ModelsID)
954 // TODO CHANGE
955 case dialog.ActionPermissionResponse:
956 m.dialog.CloseDialog(dialog.PermissionsID)
957 switch msg.Action {
958 case dialog.PermissionAllow:
959 m.com.App.Permissions.Grant(msg.Permission)
960 case dialog.PermissionAllowForSession:
961 m.com.App.Permissions.GrantPersistent(msg.Permission)
962 case dialog.PermissionDeny:
963 m.com.App.Permissions.Deny(msg.Permission)
964 }
965
966 case dialog.ActionRunCustomCommand:
967 if len(msg.Arguments) > 0 && msg.Args == nil {
968 m.dialog.CloseFrontDialog()
969 argsDialog := dialog.NewArguments(
970 m.com,
971 "Custom Command Arguments",
972 "",
973 msg.Arguments,
974 msg, // Pass the action as the result
975 )
976 m.dialog.OpenDialog(argsDialog)
977 break
978 }
979 content := msg.Content
980 if msg.Args != nil {
981 content = substituteArgs(content, msg.Args)
982 }
983 cmds = append(cmds, m.sendMessage(content))
984 m.dialog.CloseFrontDialog()
985 case dialog.ActionRunMCPPrompt:
986 if len(msg.Arguments) > 0 && msg.Args == nil {
987 m.dialog.CloseFrontDialog()
988 title := msg.Title
989 if title == "" {
990 title = "MCP Prompt Arguments"
991 }
992 argsDialog := dialog.NewArguments(
993 m.com,
994 title,
995 msg.Description,
996 msg.Arguments,
997 msg, // Pass the action as the result
998 )
999 m.dialog.OpenDialog(argsDialog)
1000 break
1001 }
1002 cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1003 default:
1004 cmds = append(cmds, uiutil.CmdHandler(msg))
1005 }
1006
1007 return tea.Batch(cmds...)
1008}
1009
1010// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1011func substituteArgs(content string, args map[string]string) string {
1012 for name, value := range args {
1013 placeholder := "$" + name
1014 content = strings.ReplaceAll(content, placeholder, value)
1015 }
1016 return content
1017}
1018
1019// openAPIKeyInputDialog opens the API key input dialog.
1020func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1021 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1022 m.dialog.BringToFront(dialog.APIKeyInputID)
1023 return nil
1024 }
1025
1026 apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1027 if err != nil {
1028 return uiutil.ReportError(err)
1029 }
1030 m.dialog.OpenDialog(apiKeyInputDialog)
1031 return nil
1032}
1033
1034func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1035 var cmds []tea.Cmd
1036
1037 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1038 switch {
1039 case key.Matches(msg, m.keyMap.Help):
1040 m.status.ToggleHelp()
1041 m.updateLayoutAndSize()
1042 return true
1043 case key.Matches(msg, m.keyMap.Commands):
1044 if cmd := m.openCommandsDialog(); cmd != nil {
1045 cmds = append(cmds, cmd)
1046 }
1047 return true
1048 case key.Matches(msg, m.keyMap.Models):
1049 if cmd := m.openModelsDialog(); cmd != nil {
1050 cmds = append(cmds, cmd)
1051 }
1052 return true
1053 case key.Matches(msg, m.keyMap.Sessions):
1054 if cmd := m.openSessionsDialog(); cmd != nil {
1055 cmds = append(cmds, cmd)
1056 }
1057 return true
1058 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1059 m.detailsOpen = !m.detailsOpen
1060 m.updateLayoutAndSize()
1061 return true
1062 }
1063 return false
1064 }
1065
1066 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1067 // Always handle quit keys first
1068 if cmd := m.openQuitDialog(); cmd != nil {
1069 cmds = append(cmds, cmd)
1070 }
1071
1072 return tea.Batch(cmds...)
1073 }
1074
1075 // Route all messages to dialog if one is open.
1076 if m.dialog.HasDialogs() {
1077 return m.handleDialogMsg(msg)
1078 }
1079
1080 // Handle cancel key when agent is busy.
1081 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1082 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1083 if cmd := m.cancelAgent(); cmd != nil {
1084 cmds = append(cmds, cmd)
1085 }
1086 return tea.Batch(cmds...)
1087 }
1088 }
1089
1090 switch m.state {
1091 case uiConfigure:
1092 return tea.Batch(cmds...)
1093 case uiInitialize:
1094 cmds = append(cmds, m.updateInitializeView(msg)...)
1095 return tea.Batch(cmds...)
1096 case uiChat, uiLanding:
1097 switch m.focus {
1098 case uiFocusEditor:
1099 // Handle completions if open.
1100 if m.completionsOpen {
1101 if msg, ok := m.completions.Update(msg); ok {
1102 switch msg := msg.(type) {
1103 case completions.SelectionMsg:
1104 // Handle file completion selection.
1105 if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1106 cmds = append(cmds, m.insertFileCompletion(item.Path))
1107 }
1108 if !msg.Insert {
1109 m.closeCompletions()
1110 }
1111 case completions.ClosedMsg:
1112 m.completionsOpen = false
1113 }
1114 return tea.Batch(cmds...)
1115 }
1116 }
1117
1118 if ok := m.attachments.Update(msg); ok {
1119 return tea.Batch(cmds...)
1120 }
1121
1122 switch {
1123 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1124 value := m.textarea.Value()
1125 if before, ok := strings.CutSuffix(value, "\\"); ok {
1126 // If the last character is a backslash, remove it and add a newline.
1127 m.textarea.SetValue(before)
1128 break
1129 }
1130
1131 // Otherwise, send the message
1132 m.textarea.Reset()
1133
1134 value = strings.TrimSpace(value)
1135 if value == "exit" || value == "quit" {
1136 return m.openQuitDialog()
1137 }
1138
1139 attachments := m.attachments.List()
1140 m.attachments.Reset()
1141 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1142 return nil
1143 }
1144
1145 m.randomizePlaceholders()
1146
1147 return m.sendMessage(value, attachments...)
1148 case key.Matches(msg, m.keyMap.Chat.NewSession):
1149 if m.session == nil || m.session.ID == "" {
1150 break
1151 }
1152 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1153 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1154 break
1155 }
1156 m.newSession()
1157 case key.Matches(msg, m.keyMap.Tab):
1158 m.focus = uiFocusMain
1159 m.textarea.Blur()
1160 m.chat.Focus()
1161 m.chat.SetSelected(m.chat.Len() - 1)
1162 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1163 if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
1164 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1165 break
1166 }
1167 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1168 case key.Matches(msg, m.keyMap.Editor.Newline):
1169 m.textarea.InsertRune('\n')
1170 m.closeCompletions()
1171 default:
1172 if handleGlobalKeys(msg) {
1173 // Handle global keys first before passing to textarea.
1174 break
1175 }
1176
1177 // Check for @ trigger before passing to textarea.
1178 curValue := m.textarea.Value()
1179 curIdx := len(curValue)
1180
1181 // Trigger completions on @.
1182 if msg.String() == "@" && !m.completionsOpen {
1183 // Only show if beginning of prompt or after whitespace.
1184 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1185 m.completionsOpen = true
1186 m.completionsQuery = ""
1187 m.completionsStartIndex = curIdx
1188 m.completionsPositionStart = m.completionsPosition()
1189 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1190 cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1191 }
1192 }
1193
1194 // remove the details if they are open when user starts typing
1195 if m.detailsOpen {
1196 m.detailsOpen = false
1197 m.updateLayoutAndSize()
1198 }
1199
1200 ta, cmd := m.textarea.Update(msg)
1201 m.textarea = ta
1202 cmds = append(cmds, cmd)
1203
1204 // After updating textarea, check if we need to filter completions.
1205 // Skip filtering on the initial @ keystroke since items are loading async.
1206 if m.completionsOpen && msg.String() != "@" {
1207 newValue := m.textarea.Value()
1208 newIdx := len(newValue)
1209
1210 // Close completions if cursor moved before start.
1211 if newIdx <= m.completionsStartIndex {
1212 m.closeCompletions()
1213 } else if msg.String() == "space" {
1214 // Close on space.
1215 m.closeCompletions()
1216 } else {
1217 // Extract current word and filter.
1218 word := m.textareaWord()
1219 if strings.HasPrefix(word, "@") {
1220 m.completionsQuery = word[1:]
1221 m.completions.Filter(m.completionsQuery)
1222 } else if m.completionsOpen {
1223 m.closeCompletions()
1224 }
1225 }
1226 }
1227 }
1228 case uiFocusMain:
1229 switch {
1230 case key.Matches(msg, m.keyMap.Tab):
1231 m.focus = uiFocusEditor
1232 cmds = append(cmds, m.textarea.Focus())
1233 m.chat.Blur()
1234 case key.Matches(msg, m.keyMap.Chat.Expand):
1235 m.chat.ToggleExpandedSelectedItem()
1236 case key.Matches(msg, m.keyMap.Chat.Up):
1237 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1238 cmds = append(cmds, cmd)
1239 }
1240 if !m.chat.SelectedItemInView() {
1241 m.chat.SelectPrev()
1242 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1243 cmds = append(cmds, cmd)
1244 }
1245 }
1246 case key.Matches(msg, m.keyMap.Chat.Down):
1247 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1248 cmds = append(cmds, cmd)
1249 }
1250 if !m.chat.SelectedItemInView() {
1251 m.chat.SelectNext()
1252 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1253 cmds = append(cmds, cmd)
1254 }
1255 }
1256 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1257 m.chat.SelectPrev()
1258 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1259 cmds = append(cmds, cmd)
1260 }
1261 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1262 m.chat.SelectNext()
1263 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1264 cmds = append(cmds, cmd)
1265 }
1266 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1267 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1268 cmds = append(cmds, cmd)
1269 }
1270 m.chat.SelectFirstInView()
1271 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1272 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1273 cmds = append(cmds, cmd)
1274 }
1275 m.chat.SelectLastInView()
1276 case key.Matches(msg, m.keyMap.Chat.PageUp):
1277 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1278 cmds = append(cmds, cmd)
1279 }
1280 m.chat.SelectFirstInView()
1281 case key.Matches(msg, m.keyMap.Chat.PageDown):
1282 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1283 cmds = append(cmds, cmd)
1284 }
1285 m.chat.SelectLastInView()
1286 case key.Matches(msg, m.keyMap.Chat.Home):
1287 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1288 cmds = append(cmds, cmd)
1289 }
1290 m.chat.SelectFirst()
1291 case key.Matches(msg, m.keyMap.Chat.End):
1292 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1293 cmds = append(cmds, cmd)
1294 }
1295 m.chat.SelectLast()
1296 default:
1297 handleGlobalKeys(msg)
1298 }
1299 default:
1300 handleGlobalKeys(msg)
1301 }
1302 default:
1303 handleGlobalKeys(msg)
1304 }
1305
1306 return tea.Batch(cmds...)
1307}
1308
1309// Draw implements [uv.Drawable] and draws the UI model.
1310func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1311 layout := m.generateLayout(area.Dx(), area.Dy())
1312
1313 if m.layout != layout {
1314 m.layout = layout
1315 m.updateSize()
1316 }
1317
1318 // Clear the screen first
1319 screen.Clear(scr)
1320
1321 switch m.state {
1322 case uiConfigure:
1323 header := uv.NewStyledString(m.header)
1324 header.Draw(scr, layout.header)
1325
1326 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1327 Height(layout.main.Dy()).
1328 Background(lipgloss.ANSIColor(rand.Intn(256))).
1329 Render(" Configure ")
1330 main := uv.NewStyledString(mainView)
1331 main.Draw(scr, layout.main)
1332
1333 case uiInitialize:
1334 header := uv.NewStyledString(m.header)
1335 header.Draw(scr, layout.header)
1336
1337 main := uv.NewStyledString(m.initializeView())
1338 main.Draw(scr, layout.main)
1339
1340 case uiLanding:
1341 header := uv.NewStyledString(m.header)
1342 header.Draw(scr, layout.header)
1343 main := uv.NewStyledString(m.landingView())
1344 main.Draw(scr, layout.main)
1345
1346 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1347 editor.Draw(scr, layout.editor)
1348
1349 case uiChat:
1350 if m.isCompact {
1351 header := uv.NewStyledString(m.header)
1352 header.Draw(scr, layout.header)
1353 } else {
1354 m.drawSidebar(scr, layout.sidebar)
1355 }
1356
1357 m.chat.Draw(scr, layout.main)
1358
1359 editorWidth := scr.Bounds().Dx()
1360 if !m.isCompact {
1361 editorWidth -= layout.sidebar.Dx()
1362 }
1363 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1364 editor.Draw(scr, layout.editor)
1365
1366 // Draw details overlay in compact mode when open
1367 if m.isCompact && m.detailsOpen {
1368 m.drawSessionDetails(scr, layout.sessionDetails)
1369 }
1370 }
1371
1372 // Add status and help layer
1373 m.status.Draw(scr, layout.status)
1374
1375 // Draw completions popup if open
1376 if m.completionsOpen && m.completions.HasItems() {
1377 w, h := m.completions.Size()
1378 x := m.completionsPositionStart.X
1379 y := m.completionsPositionStart.Y - h
1380
1381 screenW := area.Dx()
1382 if x+w > screenW {
1383 x = screenW - w
1384 }
1385 x = max(0, x)
1386 y = max(0, y)
1387
1388 completionsView := uv.NewStyledString(m.completions.Render())
1389 completionsView.Draw(scr, image.Rectangle{
1390 Min: image.Pt(x, y),
1391 Max: image.Pt(x+w, y+h),
1392 })
1393 }
1394
1395 // Debugging rendering (visually see when the tui rerenders)
1396 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1397 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1398 debug := uv.NewStyledString(debugView.String())
1399 debug.Draw(scr, image.Rectangle{
1400 Min: image.Pt(4, 1),
1401 Max: image.Pt(8, 3),
1402 })
1403 }
1404
1405 // This needs to come last to overlay on top of everything. We always pass
1406 // the full screen bounds because the dialogs will position themselves
1407 // accordingly.
1408 if m.dialog.HasDialogs() {
1409 return m.dialog.Draw(scr, scr.Bounds())
1410 }
1411
1412 switch m.focus {
1413 case uiFocusEditor:
1414 if m.layout.editor.Dy() <= 0 {
1415 // Don't show cursor if editor is not visible
1416 return nil
1417 }
1418 if m.detailsOpen && m.isCompact {
1419 // Don't show cursor if details overlay is open
1420 return nil
1421 }
1422
1423 if m.textarea.Focused() {
1424 cur := m.textarea.Cursor()
1425 cur.X++ // Adjust for app margins
1426 cur.Y += m.layout.editor.Min.Y
1427 // Offset for attachment row if present.
1428 if len(m.attachments.List()) > 0 {
1429 cur.Y++
1430 }
1431 return cur
1432 }
1433 }
1434 return nil
1435}
1436
1437// View renders the UI model's view.
1438func (m *UI) View() tea.View {
1439 var v tea.View
1440 v.AltScreen = true
1441 v.BackgroundColor = m.com.Styles.Background
1442 v.MouseMode = tea.MouseModeCellMotion
1443
1444 canvas := uv.NewScreenBuffer(m.width, m.height)
1445 v.Cursor = m.Draw(canvas, canvas.Bounds())
1446
1447 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1448 contentLines := strings.Split(content, "\n")
1449 for i, line := range contentLines {
1450 // Trim trailing spaces for concise rendering
1451 contentLines[i] = strings.TrimRight(line, " ")
1452 }
1453
1454 content = strings.Join(contentLines, "\n")
1455
1456 v.Content = content
1457 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1458 // HACK: use a random percentage to prevent ghostty from hiding it
1459 // after a timeout.
1460 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1461 }
1462
1463 return v
1464}
1465
1466// ShortHelp implements [help.KeyMap].
1467func (m *UI) ShortHelp() []key.Binding {
1468 var binds []key.Binding
1469 k := &m.keyMap
1470 tab := k.Tab
1471 commands := k.Commands
1472 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1473 commands.SetHelp("/ or ctrl+p", "commands")
1474 }
1475
1476 switch m.state {
1477 case uiInitialize:
1478 binds = append(binds, k.Quit)
1479 case uiChat:
1480 // Show cancel binding if agent is busy.
1481 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1482 cancelBinding := k.Chat.Cancel
1483 if m.isCanceling {
1484 cancelBinding.SetHelp("esc", "press again to cancel")
1485 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1486 cancelBinding.SetHelp("esc", "clear queue")
1487 }
1488 binds = append(binds, cancelBinding)
1489 }
1490
1491 if m.focus == uiFocusEditor {
1492 tab.SetHelp("tab", "focus chat")
1493 } else {
1494 tab.SetHelp("tab", "focus editor")
1495 }
1496
1497 binds = append(binds,
1498 tab,
1499 commands,
1500 k.Models,
1501 )
1502
1503 switch m.focus {
1504 case uiFocusEditor:
1505 binds = append(binds,
1506 k.Editor.Newline,
1507 )
1508 case uiFocusMain:
1509 binds = append(binds,
1510 k.Chat.UpDown,
1511 k.Chat.UpDownOneItem,
1512 k.Chat.PageUp,
1513 k.Chat.PageDown,
1514 k.Chat.Copy,
1515 )
1516 }
1517 default:
1518 // TODO: other states
1519 // if m.session == nil {
1520 // no session selected
1521 binds = append(binds,
1522 commands,
1523 k.Models,
1524 k.Editor.Newline,
1525 )
1526 }
1527
1528 binds = append(binds,
1529 k.Quit,
1530 k.Help,
1531 )
1532
1533 return binds
1534}
1535
1536// FullHelp implements [help.KeyMap].
1537func (m *UI) FullHelp() [][]key.Binding {
1538 var binds [][]key.Binding
1539 k := &m.keyMap
1540 help := k.Help
1541 help.SetHelp("ctrl+g", "less")
1542 hasAttachments := len(m.attachments.List()) > 0
1543 hasSession := m.session != nil && m.session.ID != ""
1544 commands := k.Commands
1545 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1546 commands.SetHelp("/ or ctrl+p", "commands")
1547 }
1548
1549 switch m.state {
1550 case uiInitialize:
1551 binds = append(binds,
1552 []key.Binding{
1553 k.Quit,
1554 })
1555 case uiChat:
1556 // Show cancel binding if agent is busy.
1557 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1558 cancelBinding := k.Chat.Cancel
1559 if m.isCanceling {
1560 cancelBinding.SetHelp("esc", "press again to cancel")
1561 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1562 cancelBinding.SetHelp("esc", "clear queue")
1563 }
1564 binds = append(binds, []key.Binding{cancelBinding})
1565 }
1566
1567 mainBinds := []key.Binding{}
1568 tab := k.Tab
1569 if m.focus == uiFocusEditor {
1570 tab.SetHelp("tab", "focus chat")
1571 } else {
1572 tab.SetHelp("tab", "focus editor")
1573 }
1574
1575 mainBinds = append(mainBinds,
1576 tab,
1577 commands,
1578 k.Models,
1579 k.Sessions,
1580 )
1581 if hasSession {
1582 mainBinds = append(mainBinds, k.Chat.NewSession)
1583 }
1584
1585 binds = append(binds, mainBinds)
1586
1587 switch m.focus {
1588 case uiFocusEditor:
1589 binds = append(binds,
1590 []key.Binding{
1591 k.Editor.Newline,
1592 k.Editor.AddImage,
1593 k.Editor.MentionFile,
1594 k.Editor.OpenEditor,
1595 },
1596 )
1597 if hasAttachments {
1598 binds = append(binds,
1599 []key.Binding{
1600 k.Editor.AttachmentDeleteMode,
1601 k.Editor.DeleteAllAttachments,
1602 k.Editor.Escape,
1603 },
1604 )
1605 }
1606 case uiFocusMain:
1607 binds = append(binds,
1608 []key.Binding{
1609 k.Chat.UpDown,
1610 k.Chat.UpDownOneItem,
1611 k.Chat.PageUp,
1612 k.Chat.PageDown,
1613 },
1614 []key.Binding{
1615 k.Chat.HalfPageUp,
1616 k.Chat.HalfPageDown,
1617 k.Chat.Home,
1618 k.Chat.End,
1619 },
1620 []key.Binding{
1621 k.Chat.Copy,
1622 k.Chat.ClearHighlight,
1623 },
1624 )
1625 }
1626 default:
1627 if m.session == nil {
1628 // no session selected
1629 binds = append(binds,
1630 []key.Binding{
1631 commands,
1632 k.Models,
1633 k.Sessions,
1634 },
1635 []key.Binding{
1636 k.Editor.Newline,
1637 k.Editor.AddImage,
1638 k.Editor.MentionFile,
1639 k.Editor.OpenEditor,
1640 },
1641 )
1642 if hasAttachments {
1643 binds = append(binds,
1644 []key.Binding{
1645 k.Editor.AttachmentDeleteMode,
1646 k.Editor.DeleteAllAttachments,
1647 k.Editor.Escape,
1648 },
1649 )
1650 }
1651 binds = append(binds,
1652 []key.Binding{
1653 help,
1654 },
1655 )
1656 }
1657 }
1658
1659 binds = append(binds,
1660 []key.Binding{
1661 help,
1662 k.Quit,
1663 },
1664 )
1665
1666 return binds
1667}
1668
1669// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1670func (m *UI) toggleCompactMode() tea.Cmd {
1671 m.forceCompactMode = !m.forceCompactMode
1672
1673 err := m.com.Config().SetCompactMode(m.forceCompactMode)
1674 if err != nil {
1675 return uiutil.ReportError(err)
1676 }
1677
1678 m.handleCompactMode(m.width, m.height)
1679 m.updateLayoutAndSize()
1680
1681 return nil
1682}
1683
1684// handleCompactMode updates the UI state based on window size and compact mode setting.
1685func (m *UI) handleCompactMode(newWidth, newHeight int) {
1686 if m.state == uiChat {
1687 if m.forceCompactMode {
1688 m.isCompact = true
1689 return
1690 }
1691 if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1692 m.isCompact = true
1693 } else {
1694 m.isCompact = false
1695 }
1696 }
1697}
1698
1699// updateLayoutAndSize updates the layout and sizes of UI components.
1700func (m *UI) updateLayoutAndSize() {
1701 m.layout = m.generateLayout(m.width, m.height)
1702 m.updateSize()
1703}
1704
1705// updateSize updates the sizes of UI components based on the current layout.
1706func (m *UI) updateSize() {
1707 // Set status width
1708 m.status.SetWidth(m.layout.status.Dx())
1709
1710 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1711 m.textarea.SetWidth(m.layout.editor.Dx())
1712 m.textarea.SetHeight(m.layout.editor.Dy())
1713
1714 // Handle different app states
1715 switch m.state {
1716 case uiConfigure, uiInitialize, uiLanding:
1717 m.renderHeader(false, m.layout.header.Dx())
1718
1719 case uiChat:
1720 if m.isCompact {
1721 m.renderHeader(true, m.layout.header.Dx())
1722 } else {
1723 m.renderSidebarLogo(m.layout.sidebar.Dx())
1724 }
1725 }
1726}
1727
1728// generateLayout calculates the layout rectangles for all UI components based
1729// on the current UI state and terminal dimensions.
1730func (m *UI) generateLayout(w, h int) layout {
1731 // The screen area we're working with
1732 area := image.Rect(0, 0, w, h)
1733
1734 // The help height
1735 helpHeight := 1
1736 // The editor height
1737 editorHeight := 5
1738 // The sidebar width
1739 sidebarWidth := 30
1740 // The header height
1741 const landingHeaderHeight = 4
1742
1743 var helpKeyMap help.KeyMap = m
1744 if m.status.ShowingAll() {
1745 for _, row := range helpKeyMap.FullHelp() {
1746 helpHeight = max(helpHeight, len(row))
1747 }
1748 }
1749
1750 // Add app margins
1751 appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
1752 appRect.Min.Y += 1
1753 appRect.Max.Y -= 1
1754 helpRect.Min.Y -= 1
1755 appRect.Min.X += 1
1756 appRect.Max.X -= 1
1757
1758 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1759 // extra padding on left and right for these states
1760 appRect.Min.X += 1
1761 appRect.Max.X -= 1
1762 }
1763
1764 layout := layout{
1765 area: area,
1766 status: helpRect,
1767 }
1768
1769 // Handle different app states
1770 switch m.state {
1771 case uiConfigure, uiInitialize:
1772 // Layout
1773 //
1774 // header
1775 // ------
1776 // main
1777 // ------
1778 // help
1779
1780 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1781 layout.header = headerRect
1782 layout.main = mainRect
1783
1784 case uiLanding:
1785 // Layout
1786 //
1787 // header
1788 // ------
1789 // main
1790 // ------
1791 // editor
1792 // ------
1793 // help
1794 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1795 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1796 // Remove extra padding from editor (but keep it for header and main)
1797 editorRect.Min.X -= 1
1798 editorRect.Max.X += 1
1799 layout.header = headerRect
1800 layout.main = mainRect
1801 layout.editor = editorRect
1802
1803 case uiChat:
1804 if m.isCompact {
1805 // Layout
1806 //
1807 // compact-header
1808 // ------
1809 // main
1810 // ------
1811 // editor
1812 // ------
1813 // help
1814 const compactHeaderHeight = 1
1815 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
1816 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
1817 sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
1818 layout.sessionDetails = sessionDetailsArea
1819 layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
1820 // Add one line gap between header and main content
1821 mainRect.Min.Y += 1
1822 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1823 mainRect.Max.X -= 1 // Add padding right
1824 // Add bottom margin to main
1825 mainRect.Max.Y -= 1
1826 layout.header = headerRect
1827 layout.main = mainRect
1828 layout.editor = editorRect
1829 } else {
1830 // Layout
1831 //
1832 // ------|---
1833 // main |
1834 // ------| side
1835 // editor|
1836 // ----------
1837 // help
1838
1839 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1840 // Add padding left
1841 sideRect.Min.X += 1
1842 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1843 mainRect.Max.X -= 1 // Add padding right
1844 // Add bottom margin to main
1845 mainRect.Max.Y -= 1
1846 layout.sidebar = sideRect
1847 layout.main = mainRect
1848 layout.editor = editorRect
1849 }
1850 }
1851
1852 if !layout.editor.Empty() {
1853 // Add editor margins 1 top and bottom
1854 layout.editor.Min.Y += 1
1855 layout.editor.Max.Y -= 1
1856 }
1857
1858 return layout
1859}
1860
1861// layout defines the positioning of UI elements.
1862type layout struct {
1863 // area is the overall available area.
1864 area uv.Rectangle
1865
1866 // header is the header shown in special cases
1867 // e.x when the sidebar is collapsed
1868 // or when in the landing page
1869 // or in init/config
1870 header uv.Rectangle
1871
1872 // main is the area for the main pane. (e.x chat, configure, landing)
1873 main uv.Rectangle
1874
1875 // editor is the area for the editor pane.
1876 editor uv.Rectangle
1877
1878 // sidebar is the area for the sidebar.
1879 sidebar uv.Rectangle
1880
1881 // status is the area for the status view.
1882 status uv.Rectangle
1883
1884 // session details is the area for the session details overlay in compact mode.
1885 sessionDetails uv.Rectangle
1886}
1887
1888func (m *UI) openEditor(value string) tea.Cmd {
1889 tmpfile, err := os.CreateTemp("", "msg_*.md")
1890 if err != nil {
1891 return uiutil.ReportError(err)
1892 }
1893 defer tmpfile.Close() //nolint:errcheck
1894 if _, err := tmpfile.WriteString(value); err != nil {
1895 return uiutil.ReportError(err)
1896 }
1897 cmd, err := editor.Command(
1898 "crush",
1899 tmpfile.Name(),
1900 editor.AtPosition(
1901 m.textarea.Line()+1,
1902 m.textarea.Column()+1,
1903 ),
1904 )
1905 if err != nil {
1906 return uiutil.ReportError(err)
1907 }
1908 return tea.ExecProcess(cmd, func(err error) tea.Msg {
1909 if err != nil {
1910 return uiutil.ReportError(err)
1911 }
1912 content, err := os.ReadFile(tmpfile.Name())
1913 if err != nil {
1914 return uiutil.ReportError(err)
1915 }
1916 if len(content) == 0 {
1917 return uiutil.ReportWarn("Message is empty")
1918 }
1919 os.Remove(tmpfile.Name())
1920 return openEditorMsg{
1921 Text: strings.TrimSpace(string(content)),
1922 }
1923 })
1924}
1925
1926// setEditorPrompt configures the textarea prompt function based on whether
1927// yolo mode is enabled.
1928func (m *UI) setEditorPrompt(yolo bool) {
1929 if yolo {
1930 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1931 return
1932 }
1933 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1934}
1935
1936// normalPromptFunc returns the normal editor prompt style (" > " on first
1937// line, "::: " on subsequent lines).
1938func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1939 t := m.com.Styles
1940 if info.LineNumber == 0 {
1941 if info.Focused {
1942 return " > "
1943 }
1944 return "::: "
1945 }
1946 if info.Focused {
1947 return t.EditorPromptNormalFocused.Render()
1948 }
1949 return t.EditorPromptNormalBlurred.Render()
1950}
1951
1952// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1953// and colored dots.
1954func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1955 t := m.com.Styles
1956 if info.LineNumber == 0 {
1957 if info.Focused {
1958 return t.EditorPromptYoloIconFocused.Render()
1959 } else {
1960 return t.EditorPromptYoloIconBlurred.Render()
1961 }
1962 }
1963 if info.Focused {
1964 return t.EditorPromptYoloDotsFocused.Render()
1965 }
1966 return t.EditorPromptYoloDotsBlurred.Render()
1967}
1968
1969// closeCompletions closes the completions popup and resets state.
1970func (m *UI) closeCompletions() {
1971 m.completionsOpen = false
1972 m.completionsQuery = ""
1973 m.completionsStartIndex = 0
1974 m.completions.Close()
1975}
1976
1977// insertFileCompletion inserts the selected file path into the textarea,
1978// replacing the @query, and adds the file as an attachment.
1979func (m *UI) insertFileCompletion(path string) tea.Cmd {
1980 value := m.textarea.Value()
1981 word := m.textareaWord()
1982
1983 // Find the @ and query to replace.
1984 if m.completionsStartIndex > len(value) {
1985 return nil
1986 }
1987
1988 // Build the new value: everything before @, the path, everything after query.
1989 endIdx := min(m.completionsStartIndex+len(word), len(value))
1990
1991 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1992 m.textarea.SetValue(newValue)
1993 m.textarea.MoveToEnd()
1994 m.textarea.InsertRune(' ')
1995
1996 return func() tea.Msg {
1997 absPath, _ := filepath.Abs(path)
1998 // Skip attachment if file was already read and hasn't been modified.
1999 lastRead := filetracker.LastReadTime(absPath)
2000 if !lastRead.IsZero() {
2001 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2002 return nil
2003 }
2004 }
2005
2006 // Add file as attachment.
2007 content, err := os.ReadFile(path)
2008 if err != nil {
2009 // If it fails, let the LLM handle it later.
2010 return nil
2011 }
2012 filetracker.RecordRead(absPath)
2013
2014 return message.Attachment{
2015 FilePath: path,
2016 FileName: filepath.Base(path),
2017 MimeType: mimeOf(content),
2018 Content: content,
2019 }
2020 }
2021}
2022
2023// completionsPosition returns the X and Y position for the completions popup.
2024func (m *UI) completionsPosition() image.Point {
2025 cur := m.textarea.Cursor()
2026 if cur == nil {
2027 return image.Point{
2028 X: m.layout.editor.Min.X,
2029 Y: m.layout.editor.Min.Y,
2030 }
2031 }
2032 return image.Point{
2033 X: cur.X + m.layout.editor.Min.X,
2034 Y: m.layout.editor.Min.Y + cur.Y,
2035 }
2036}
2037
2038// textareaWord returns the current word at the cursor position.
2039func (m *UI) textareaWord() string {
2040 return m.textarea.Word()
2041}
2042
2043// isWhitespace returns true if the byte is a whitespace character.
2044func isWhitespace(b byte) bool {
2045 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2046}
2047
2048// mimeOf detects the MIME type of the given content.
2049func mimeOf(content []byte) string {
2050 mimeBufferSize := min(512, len(content))
2051 return http.DetectContentType(content[:mimeBufferSize])
2052}
2053
2054var readyPlaceholders = [...]string{
2055 "Ready!",
2056 "Ready...",
2057 "Ready?",
2058 "Ready for instructions",
2059}
2060
2061var workingPlaceholders = [...]string{
2062 "Working!",
2063 "Working...",
2064 "Brrrrr...",
2065 "Prrrrrrrr...",
2066 "Processing...",
2067 "Thinking...",
2068}
2069
2070// randomizePlaceholders selects random placeholder text for the textarea's
2071// ready and working states.
2072func (m *UI) randomizePlaceholders() {
2073 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2074 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2075}
2076
2077// renderEditorView renders the editor view with attachments if any.
2078func (m *UI) renderEditorView(width int) string {
2079 if len(m.attachments.List()) == 0 {
2080 return m.textarea.View()
2081 }
2082 return lipgloss.JoinVertical(
2083 lipgloss.Top,
2084 m.attachments.Render(width),
2085 m.textarea.View(),
2086 )
2087}
2088
2089// renderHeader renders and caches the header logo at the specified width.
2090func (m *UI) renderHeader(compact bool, width int) {
2091 if compact && m.session != nil && m.com.App != nil {
2092 m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2093 } else {
2094 m.header = renderLogo(m.com.Styles, compact, width)
2095 }
2096}
2097
2098// renderSidebarLogo renders and caches the sidebar logo at the specified
2099// width.
2100func (m *UI) renderSidebarLogo(width int) {
2101 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2102}
2103
2104// sendMessage sends a message with the given content and attachments.
2105func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2106 if m.com.App.AgentCoordinator == nil {
2107 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2108 }
2109
2110 var cmds []tea.Cmd
2111 if m.session == nil || m.session.ID == "" {
2112 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2113 if err != nil {
2114 return uiutil.ReportError(err)
2115 }
2116 m.state = uiChat
2117 if m.forceCompactMode {
2118 m.isCompact = true
2119 }
2120 if newSession.ID != "" {
2121 m.session = &newSession
2122 cmds = append(cmds, m.loadSession(newSession.ID))
2123 }
2124 }
2125
2126 // Capture session ID to avoid race with main goroutine updating m.session.
2127 sessionID := m.session.ID
2128 cmds = append(cmds, func() tea.Msg {
2129 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2130 if err != nil {
2131 isCancelErr := errors.Is(err, context.Canceled)
2132 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2133 if isCancelErr || isPermissionErr {
2134 return nil
2135 }
2136 return uiutil.InfoMsg{
2137 Type: uiutil.InfoTypeError,
2138 Msg: err.Error(),
2139 }
2140 }
2141 return nil
2142 })
2143 return tea.Batch(cmds...)
2144}
2145
2146const cancelTimerDuration = 2 * time.Second
2147
2148// cancelTimerCmd creates a command that expires the cancel timer.
2149func cancelTimerCmd() tea.Cmd {
2150 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2151 return cancelTimerExpiredMsg{}
2152 })
2153}
2154
2155// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2156// and starts a timer. The second press (before the timer expires) actually
2157// cancels the agent.
2158func (m *UI) cancelAgent() tea.Cmd {
2159 if m.session == nil || m.session.ID == "" {
2160 return nil
2161 }
2162
2163 coordinator := m.com.App.AgentCoordinator
2164 if coordinator == nil {
2165 return nil
2166 }
2167
2168 if m.isCanceling {
2169 // Second escape press - actually cancel the agent.
2170 m.isCanceling = false
2171 coordinator.Cancel(m.session.ID)
2172 return nil
2173 }
2174
2175 // Check if there are queued prompts - if so, clear the queue.
2176 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2177 coordinator.ClearQueue(m.session.ID)
2178 return nil
2179 }
2180
2181 // First escape press - set canceling state and start timer.
2182 m.isCanceling = true
2183 return cancelTimerCmd()
2184}
2185
2186// openDialog opens a dialog by its ID.
2187func (m *UI) openDialog(id string) tea.Cmd {
2188 var cmds []tea.Cmd
2189 switch id {
2190 case dialog.SessionsID:
2191 if cmd := m.openSessionsDialog(); cmd != nil {
2192 cmds = append(cmds, cmd)
2193 }
2194 case dialog.ModelsID:
2195 if cmd := m.openModelsDialog(); cmd != nil {
2196 cmds = append(cmds, cmd)
2197 }
2198 case dialog.CommandsID:
2199 if cmd := m.openCommandsDialog(); cmd != nil {
2200 cmds = append(cmds, cmd)
2201 }
2202 case dialog.QuitID:
2203 if cmd := m.openQuitDialog(); cmd != nil {
2204 cmds = append(cmds, cmd)
2205 }
2206 default:
2207 // Unknown dialog
2208 break
2209 }
2210 return tea.Batch(cmds...)
2211}
2212
2213// openQuitDialog opens the quit confirmation dialog.
2214func (m *UI) openQuitDialog() tea.Cmd {
2215 if m.dialog.ContainsDialog(dialog.QuitID) {
2216 // Bring to front
2217 m.dialog.BringToFront(dialog.QuitID)
2218 return nil
2219 }
2220
2221 quitDialog := dialog.NewQuit(m.com)
2222 m.dialog.OpenDialog(quitDialog)
2223 return nil
2224}
2225
2226// openModelsDialog opens the models dialog.
2227func (m *UI) openModelsDialog() tea.Cmd {
2228 if m.dialog.ContainsDialog(dialog.ModelsID) {
2229 // Bring to front
2230 m.dialog.BringToFront(dialog.ModelsID)
2231 return nil
2232 }
2233
2234 modelsDialog, err := dialog.NewModels(m.com)
2235 if err != nil {
2236 return uiutil.ReportError(err)
2237 }
2238
2239 m.dialog.OpenDialog(modelsDialog)
2240
2241 return nil
2242}
2243
2244// openCommandsDialog opens the commands dialog.
2245func (m *UI) openCommandsDialog() tea.Cmd {
2246 if m.dialog.ContainsDialog(dialog.CommandsID) {
2247 // Bring to front
2248 m.dialog.BringToFront(dialog.CommandsID)
2249 return nil
2250 }
2251
2252 sessionID := ""
2253 if m.session != nil {
2254 sessionID = m.session.ID
2255 }
2256
2257 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2258 if err != nil {
2259 return uiutil.ReportError(err)
2260 }
2261
2262 m.dialog.OpenDialog(commands)
2263
2264 return nil
2265}
2266
2267// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2268// it brings it to the front. Otherwise, it will list all the sessions and open
2269// the dialog.
2270func (m *UI) openSessionsDialog() tea.Cmd {
2271 if m.dialog.ContainsDialog(dialog.SessionsID) {
2272 // Bring to front
2273 m.dialog.BringToFront(dialog.SessionsID)
2274 return nil
2275 }
2276
2277 selectedSessionID := ""
2278 if m.session != nil {
2279 selectedSessionID = m.session.ID
2280 }
2281
2282 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2283 if err != nil {
2284 return uiutil.ReportError(err)
2285 }
2286
2287 m.dialog.OpenDialog(dialog)
2288
2289 return nil
2290}
2291
2292// openPermissionsDialog opens the permissions dialog for a permission request.
2293func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2294 // Close any existing permissions dialog first.
2295 m.dialog.CloseDialog(dialog.PermissionsID)
2296
2297 // Get diff mode from config.
2298 var opts []dialog.PermissionsOption
2299 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2300 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2301 }
2302
2303 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2304 m.dialog.OpenDialog(permDialog)
2305 return nil
2306}
2307
2308// handlePermissionNotification updates tool items when permission state changes.
2309func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2310 toolItem := m.chat.MessageItem(notification.ToolCallID)
2311 if toolItem == nil {
2312 return
2313 }
2314
2315 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2316 if notification.Granted {
2317 permItem.SetStatus(chat.ToolStatusRunning)
2318 } else {
2319 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2320 }
2321 }
2322}
2323
2324// newSession clears the current session state and prepares for a new session.
2325// The actual session creation happens when the user sends their first message.
2326func (m *UI) newSession() {
2327 if m.session == nil || m.session.ID == "" {
2328 return
2329 }
2330
2331 m.session = nil
2332 m.sessionFiles = nil
2333 m.state = uiLanding
2334 m.focus = uiFocusEditor
2335 m.textarea.Focus()
2336 m.chat.Blur()
2337 m.chat.ClearMessages()
2338}
2339
2340// handlePasteMsg handles a paste message.
2341func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2342 if m.dialog.HasDialogs() {
2343 return m.handleDialogMsg(msg)
2344 }
2345
2346 if m.focus != uiFocusEditor {
2347 return nil
2348 }
2349
2350 // If pasted text has more than 2 newlines, treat it as a file attachment.
2351 if strings.Count(msg.Content, "\n") > 2 {
2352 return func() tea.Msg {
2353 content := []byte(msg.Content)
2354 if int64(len(content)) > maxAttachmentSize {
2355 return uiutil.ReportWarn("Paste is too big (>5mb)")
2356 }
2357 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2358 mimeBufferSize := min(512, len(content))
2359 mimeType := http.DetectContentType(content[:mimeBufferSize])
2360 return message.Attachment{
2361 FileName: name,
2362 FilePath: name,
2363 MimeType: mimeType,
2364 Content: content,
2365 }
2366 }
2367 }
2368
2369 var cmd tea.Cmd
2370 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2371 // Try to get an image.
2372 path, err := filepath.Abs(strings.TrimSpace(path))
2373 if err != nil {
2374 m.textarea, cmd = m.textarea.Update(msg)
2375 return cmd
2376 }
2377
2378 // Check if file has an allowed image extension.
2379 isAllowedType := false
2380 lowerPath := strings.ToLower(path)
2381 for _, ext := range allowedImageTypes {
2382 if strings.HasSuffix(lowerPath, ext) {
2383 isAllowedType = true
2384 break
2385 }
2386 }
2387 if !isAllowedType {
2388 m.textarea, cmd = m.textarea.Update(msg)
2389 return cmd
2390 }
2391
2392 return func() tea.Msg {
2393 fileInfo, err := os.Stat(path)
2394 if err != nil {
2395 return uiutil.ReportError(err)
2396 }
2397 if fileInfo.Size() > maxAttachmentSize {
2398 return uiutil.ReportWarn("File is too big (>5mb)")
2399 }
2400
2401 content, err := os.ReadFile(path)
2402 if err != nil {
2403 return uiutil.ReportError(err)
2404 }
2405
2406 mimeBufferSize := min(512, len(content))
2407 mimeType := http.DetectContentType(content[:mimeBufferSize])
2408 fileName := filepath.Base(path)
2409 return message.Attachment{
2410 FilePath: path,
2411 FileName: fileName,
2412 MimeType: mimeType,
2413 Content: content,
2414 }
2415 }
2416}
2417
2418var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2419
2420func (m *UI) pasteIdx() int {
2421 result := 0
2422 for _, at := range m.attachments.List() {
2423 found := pasteRE.FindStringSubmatch(at.FileName)
2424 if len(found) == 0 {
2425 continue
2426 }
2427 idx, err := strconv.Atoi(found[1])
2428 if err == nil {
2429 result = max(result, idx)
2430 }
2431 }
2432 return result + 1
2433}
2434
2435// drawSessionDetails draws the session details in compact mode.
2436func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2437 if m.session == nil {
2438 return
2439 }
2440
2441 s := m.com.Styles
2442
2443 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2444 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2445
2446 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2447 blocks := []string{
2448 title,
2449 "",
2450 m.modelInfo(width),
2451 "",
2452 }
2453
2454 detailsHeader := lipgloss.JoinVertical(
2455 lipgloss.Left,
2456 blocks...,
2457 )
2458
2459 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2460
2461 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2462
2463 const maxSectionWidth = 50
2464 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2465 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2466
2467 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2468 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2469 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2470 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2471 uv.NewStyledString(
2472 s.CompactDetails.View.
2473 Width(area.Dx()).
2474 Render(
2475 lipgloss.JoinVertical(
2476 lipgloss.Left,
2477 detailsHeader,
2478 sections,
2479 version,
2480 ),
2481 ),
2482 ).Draw(scr, area)
2483}
2484
2485func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2486 load := func() tea.Msg {
2487 prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2488 if err != nil {
2489 // TODO: make this better
2490 return uiutil.ReportError(err)()
2491 }
2492
2493 if prompt == "" {
2494 return nil
2495 }
2496 return sendMessageMsg{
2497 Content: prompt,
2498 }
2499 }
2500
2501 var cmds []tea.Cmd
2502 if cmd := m.dialog.StartLoading(); cmd != nil {
2503 cmds = append(cmds, cmd)
2504 }
2505 cmds = append(cmds, load, func() tea.Msg {
2506 return closeDialogMsg{}
2507 })
2508
2509 return tea.Sequence(cmds...)
2510}
2511
2512// renderLogo renders the Crush logo with the given styles and dimensions.
2513func renderLogo(t *styles.Styles, compact bool, width int) string {
2514 return logo.Render(version.Version, compact, logo.Opts{
2515 FieldColor: t.LogoFieldColor,
2516 TitleColorA: t.LogoTitleColorA,
2517 TitleColorB: t.LogoTitleColorB,
2518 CharmColor: t.LogoCharmColor,
2519 VersionColor: t.LogoVersionColor,
2520 Width: width,
2521 })
2522}