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