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