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