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