1package model
2
3import (
4 "context"
5 "image"
6 "math/rand"
7 "os"
8 "slices"
9 "strings"
10
11 "charm.land/bubbles/v2/help"
12 "charm.land/bubbles/v2/key"
13 "charm.land/bubbles/v2/textarea"
14 tea "charm.land/bubbletea/v2"
15 "charm.land/lipgloss/v2"
16 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
17 "github.com/charmbracelet/crush/internal/app"
18 "github.com/charmbracelet/crush/internal/config"
19 "github.com/charmbracelet/crush/internal/history"
20 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/ui/common"
23 "github.com/charmbracelet/crush/internal/ui/dialog"
24 "github.com/charmbracelet/crush/internal/ui/logo"
25 "github.com/charmbracelet/crush/internal/ui/styles"
26 "github.com/charmbracelet/crush/internal/version"
27 uv "github.com/charmbracelet/ultraviolet"
28 "github.com/charmbracelet/ultraviolet/screen"
29 "github.com/charmbracelet/x/ansi"
30)
31
32// uiFocusState represents the current focus state of the UI.
33type uiFocusState uint8
34
35// Possible uiFocusState values.
36const (
37 uiFocusNone uiFocusState = iota
38 uiFocusEditor
39 uiFocusMain
40)
41
42type uiState uint8
43
44// Possible uiState values.
45const (
46 uiConfigure uiState = iota
47 uiInitialize
48 uiLanding
49 uiChat
50 uiChatCompact
51)
52
53type sessionLoadedMsg struct {
54 sess session.Session
55}
56
57type sessionFilesLoadedMsg struct {
58 files []SessionFile
59}
60
61// UI represents the main user interface model.
62type UI struct {
63 com *common.Common
64 session *session.Session
65 sessionFiles []SessionFile
66
67 // The width and height of the terminal in cells.
68 width int
69 height int
70 layout layout
71
72 focus uiFocusState
73 state uiState
74
75 keyMap KeyMap
76 keyenh tea.KeyboardEnhancementsMsg
77
78 dialog *dialog.Overlay
79 help help.Model
80
81 // header is the last cached header logo
82 header string
83
84 // sendProgressBar instructs the TUI to send progress bar updates to the
85 // terminal.
86 sendProgressBar bool
87
88 // QueryVersion instructs the TUI to query for the terminal version when it
89 // starts.
90 QueryVersion bool
91
92 // Editor components
93 textarea textarea.Model
94
95 attachments []any // TODO: Implement attachments
96
97 readyPlaceholder string
98 workingPlaceholder string
99
100 // Chat components
101 chat *Chat
102
103 // onboarding state
104 onboarding struct {
105 yesInitializeSelected bool
106 }
107
108 // lsp
109 lspStates map[string]app.LSPClientInfo
110
111 // mcp
112 mcpStates map[string]mcp.ClientInfo
113
114 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
115 sidebarLogo string
116
117 // Canvas for rendering
118 canvas *uv.ScreenBuffer
119}
120
121// New creates a new instance of the [UI] model.
122func New(com *common.Common) *UI {
123 // Editor components
124 ta := textarea.New()
125 ta.SetStyles(com.Styles.TextArea)
126 ta.ShowLineNumbers = false
127 ta.CharLimit = -1
128 ta.SetVirtualCursor(false)
129 ta.Focus()
130
131 ch := NewChat(com)
132
133 // TODO: Switch to lipgloss.Canvas when available
134 canvas := uv.NewScreenBuffer(0, 0)
135 canvas.Method = ansi.GraphemeWidth
136
137 ui := &UI{
138 com: com,
139 dialog: dialog.NewOverlay(),
140 keyMap: DefaultKeyMap(),
141 help: help.New(),
142 focus: uiFocusNone,
143 state: uiConfigure,
144 textarea: ta,
145 chat: ch,
146 canvas: &canvas,
147 }
148
149 // set onboarding state defaults
150 ui.onboarding.yesInitializeSelected = true
151
152 // If no provider is configured show the user the provider list
153 if !com.Config().IsConfigured() {
154 ui.state = uiConfigure
155 // if the project needs initialization show the user the question
156 } else if n, _ := config.ProjectNeedsInitialization(); n {
157 ui.state = uiInitialize
158 // otherwise go to the landing UI
159 } else {
160 ui.state = uiLanding
161 ui.focus = uiFocusEditor
162 }
163
164 ui.setEditorPrompt()
165 ui.randomizePlaceholders()
166 ui.textarea.Placeholder = ui.readyPlaceholder
167 ui.help.Styles = com.Styles.Help
168
169 return ui
170}
171
172// Init initializes the UI model.
173func (m *UI) Init() tea.Cmd {
174 var cmds []tea.Cmd
175 if m.QueryVersion {
176 cmds = append(cmds, tea.RequestTerminalVersion)
177 }
178 allSessions, _ := m.com.App.Sessions.List(context.Background())
179 if len(allSessions) > 0 {
180 cmds = append(cmds, m.loadSession(allSessions[0].ID))
181 }
182 return tea.Batch(cmds...)
183}
184
185// Update handles updates to the UI model.
186func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187 var cmds []tea.Cmd
188 switch msg := msg.(type) {
189 case tea.EnvMsg:
190 // Is this Windows Terminal?
191 if !m.sendProgressBar {
192 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
193 }
194 case sessionLoadedMsg:
195 m.state = uiChat
196 m.session = &msg.sess
197 // Load the last 20 messages from this session.
198 msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
199 for _, message := range msgs {
200 m.chat.AppendMessage(message)
201 }
202 m.chat.ScrollToBottom()
203 case sessionFilesLoadedMsg:
204 m.sessionFiles = msg.files
205 case pubsub.Event[history.File]:
206 cmds = append(cmds, m.handleFileEvent(msg.Payload))
207 case pubsub.Event[app.LSPEvent]:
208 m.lspStates = app.GetLSPStates()
209 case pubsub.Event[mcp.Event]:
210 m.mcpStates = mcp.GetStates()
211 case tea.TerminalVersionMsg:
212 termVersion := strings.ToLower(msg.Name)
213 // Only enable progress bar for the following terminals.
214 if !m.sendProgressBar {
215 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
216 }
217 return m, nil
218 case tea.WindowSizeMsg:
219 m.width, m.height = msg.Width, msg.Height
220 m.updateLayoutAndSize()
221 m.canvas.Resize(msg.Width, msg.Height)
222 case tea.KeyboardEnhancementsMsg:
223 m.keyenh = msg
224 if msg.SupportsKeyDisambiguation() {
225 m.keyMap.Models.SetHelp("ctrl+m", "models")
226 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
227 }
228 case tea.MouseClickMsg:
229 switch m.state {
230 case uiChat:
231 m.chat.HandleMouseDown(msg.X, msg.Y)
232 }
233
234 case tea.MouseMotionMsg:
235 switch m.state {
236 case uiChat:
237 if msg.Y <= 0 {
238 m.chat.ScrollBy(-1)
239 } else if msg.Y >= m.chat.Height()-1 {
240 m.chat.ScrollBy(1)
241 }
242 m.chat.HandleMouseDrag(msg.X, msg.Y)
243 }
244
245 case tea.MouseReleaseMsg:
246 switch m.state {
247 case uiChat:
248 m.chat.HandleMouseUp(msg.X, msg.Y)
249 }
250 case tea.MouseWheelMsg:
251 switch m.state {
252 case uiChat:
253 switch msg.Button {
254 case tea.MouseWheelUp:
255 m.chat.ScrollBy(-5)
256 if !m.chat.SelectedItemInView() {
257 m.chat.SelectPrev()
258 m.chat.ScrollToSelected()
259 }
260 case tea.MouseWheelDown:
261 m.chat.ScrollBy(5)
262 if !m.chat.SelectedItemInView() {
263 m.chat.SelectNext()
264 m.chat.ScrollToSelected()
265 }
266 }
267 }
268 case tea.KeyPressMsg:
269 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
270 }
271
272 // This logic gets triggered on any message type, but should it?
273 switch m.focus {
274 case uiFocusMain:
275 case uiFocusEditor:
276 // Textarea placeholder logic
277 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
278 m.textarea.Placeholder = m.workingPlaceholder
279 } else {
280 m.textarea.Placeholder = m.readyPlaceholder
281 }
282 if m.com.App.Permissions.SkipRequests() {
283 m.textarea.Placeholder = "Yolo mode!"
284 }
285 }
286
287 return m, tea.Batch(cmds...)
288}
289
290func (m *UI) loadSession(sessionID string) tea.Cmd {
291 return func() tea.Msg {
292 // TODO: handle error
293 session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
294 return sessionLoadedMsg{session}
295 }
296}
297
298func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
299 if m.dialog.HasDialogs() {
300 return m.updateDialogs(msg)
301 }
302
303 switch {
304 case key.Matches(msg, m.keyMap.Tab):
305 switch m.state {
306 case uiChat:
307 if m.focus == uiFocusMain {
308 m.focus = uiFocusEditor
309 cmds = append(cmds, m.textarea.Focus())
310 m.chat.Blur()
311 } else {
312 m.focus = uiFocusMain
313 m.textarea.Blur()
314 m.chat.Focus()
315 m.chat.SetSelectedIndex(m.chat.Len() - 1)
316 }
317 }
318 case key.Matches(msg, m.keyMap.Help):
319 m.help.ShowAll = !m.help.ShowAll
320 m.updateLayoutAndSize()
321 return cmds
322 case key.Matches(msg, m.keyMap.Quit):
323 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
324 m.dialog.AddDialog(dialog.NewQuit(m.com))
325 return
326 }
327 return cmds
328 case key.Matches(msg, m.keyMap.Commands):
329 // TODO: Implement me
330 return cmds
331 case key.Matches(msg, m.keyMap.Models):
332 // TODO: Implement me
333 return cmds
334 case key.Matches(msg, m.keyMap.Sessions):
335 // TODO: Implement me
336 return cmds
337 }
338
339 cmds = append(cmds, m.updateFocused(msg)...)
340 return cmds
341}
342
343// Draw implements [tea.Layer] and draws the UI model.
344func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
345 layout := generateLayout(m, area.Dx(), area.Dy())
346
347 if m.layout != layout {
348 m.layout = layout
349 m.updateSize()
350 }
351
352 // Clear the screen first
353 screen.Clear(scr)
354
355 switch m.state {
356 case uiConfigure:
357 header := uv.NewStyledString(m.header)
358 header.Draw(scr, layout.header)
359
360 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
361 Height(layout.main.Dy()).
362 Background(lipgloss.ANSIColor(rand.Intn(256))).
363 Render(" Configure ")
364 main := uv.NewStyledString(mainView)
365 main.Draw(scr, layout.main)
366
367 case uiInitialize:
368 header := uv.NewStyledString(m.header)
369 header.Draw(scr, layout.header)
370
371 main := uv.NewStyledString(m.initializeView())
372 main.Draw(scr, layout.main)
373
374 case uiLanding:
375 header := uv.NewStyledString(m.header)
376 header.Draw(scr, layout.header)
377 main := uv.NewStyledString(m.landingView())
378 main.Draw(scr, layout.main)
379
380 editor := uv.NewStyledString(m.textarea.View())
381 editor.Draw(scr, layout.editor)
382
383 case uiChat:
384 header := uv.NewStyledString(m.header)
385 header.Draw(scr, layout.header)
386 m.drawSidebar(scr, layout.sidebar)
387
388 m.chat.Draw(scr, layout.main)
389
390 editor := uv.NewStyledString(m.textarea.View())
391 editor.Draw(scr, layout.editor)
392
393 case uiChatCompact:
394 header := uv.NewStyledString(m.header)
395 header.Draw(scr, layout.header)
396
397 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
398 Height(layout.main.Dy()).
399 Background(lipgloss.ANSIColor(rand.Intn(256))).
400 Render(" Compact Chat Messages ")
401 main := uv.NewStyledString(mainView)
402 main.Draw(scr, layout.main)
403
404 editor := uv.NewStyledString(m.textarea.View())
405 editor.Draw(scr, layout.editor)
406 }
407
408 // Add help layer
409 help := uv.NewStyledString(m.help.View(m))
410 help.Draw(scr, layout.help)
411
412 // Debugging rendering (visually see when the tui rerenders)
413 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
414 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
415 debug := uv.NewStyledString(debugView.String())
416 debug.Draw(scr, image.Rectangle{
417 Min: image.Pt(4, 1),
418 Max: image.Pt(8, 3),
419 })
420 }
421
422 // This needs to come last to overlay on top of everything
423 if m.dialog.HasDialogs() {
424 if dialogView := m.dialog.View(); dialogView != "" {
425 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
426 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
427 dialog := uv.NewStyledString(dialogView)
428 dialog.Draw(scr, dialogArea)
429 }
430 }
431}
432
433// Cursor returns the cursor position and properties for the UI model. It
434// returns nil if the cursor should not be shown.
435func (m *UI) Cursor() *tea.Cursor {
436 if m.layout.editor.Dy() <= 0 {
437 // Don't show cursor if editor is not visible
438 return nil
439 }
440 if m.focus == uiFocusEditor && m.textarea.Focused() {
441 cur := m.textarea.Cursor()
442 cur.X++ // Adjust for app margins
443 cur.Y += m.layout.editor.Min.Y
444 return cur
445 }
446 return nil
447}
448
449// View renders the UI model's view.
450func (m *UI) View() tea.View {
451 var v tea.View
452 v.AltScreen = true
453 v.BackgroundColor = m.com.Styles.Background
454 v.Cursor = m.Cursor()
455 v.MouseMode = tea.MouseModeCellMotion
456
457 m.Draw(m.canvas, m.canvas.Bounds())
458
459 content := strings.ReplaceAll(m.canvas.Render(), "\r\n", "\n") // normalize newlines
460 contentLines := strings.Split(content, "\n")
461 for i, line := range contentLines {
462 // Trim trailing spaces for concise rendering
463 contentLines[i] = strings.TrimRight(line, " ")
464 }
465
466 content = strings.Join(contentLines, "\n")
467 v.Content = content
468 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
469 // HACK: use a random percentage to prevent ghostty from hiding it
470 // after a timeout.
471 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
472 }
473
474 return v
475}
476
477// ShortHelp implements [help.KeyMap].
478func (m *UI) ShortHelp() []key.Binding {
479 var binds []key.Binding
480 k := &m.keyMap
481
482 switch m.state {
483 case uiInitialize:
484 binds = append(binds, k.Quit)
485 default:
486 // TODO: other states
487 // if m.session == nil {
488 // no session selected
489 binds = append(binds,
490 k.Commands,
491 k.Models,
492 k.Editor.Newline,
493 k.Quit,
494 k.Help,
495 )
496 // }
497 // else {
498 // we have a session
499 // }
500
501 // switch m.state {
502 // case uiChat:
503 // case uiEdit:
504 // binds = append(binds,
505 // k.Editor.AddFile,
506 // k.Editor.SendMessage,
507 // k.Editor.OpenEditor,
508 // k.Editor.Newline,
509 // )
510 //
511 // if len(m.attachments) > 0 {
512 // binds = append(binds,
513 // k.Editor.AttachmentDeleteMode,
514 // k.Editor.DeleteAllAttachments,
515 // k.Editor.Escape,
516 // )
517 // }
518 // }
519 }
520
521 return binds
522}
523
524// FullHelp implements [help.KeyMap].
525func (m *UI) FullHelp() [][]key.Binding {
526 var binds [][]key.Binding
527 k := &m.keyMap
528 help := k.Help
529 help.SetHelp("ctrl+g", "less")
530
531 switch m.state {
532 case uiInitialize:
533 binds = append(binds,
534 []key.Binding{
535 k.Quit,
536 })
537 default:
538 if m.session == nil {
539 // no session selected
540 binds = append(binds,
541 []key.Binding{
542 k.Commands,
543 k.Models,
544 k.Sessions,
545 },
546 []key.Binding{
547 k.Editor.Newline,
548 k.Editor.AddImage,
549 k.Editor.MentionFile,
550 k.Editor.OpenEditor,
551 },
552 []key.Binding{
553 help,
554 },
555 )
556 }
557 // else {
558 // we have a session
559 // }
560 }
561
562 // switch m.state {
563 // case uiChat:
564 // case uiEdit:
565 // binds = append(binds, m.ShortHelp())
566 // }
567
568 return binds
569}
570
571// updateDialogs updates the dialog overlay with the given message and returns cmds
572func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
573 updatedDialog, cmd := m.dialog.Update(msg)
574 m.dialog = updatedDialog
575 cmds = append(cmds, cmd)
576 return cmds
577}
578
579// updateFocused updates the focused model (chat or editor) with the given message
580// and appends any resulting commands to the cmds slice.
581func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
582 switch m.state {
583 case uiConfigure:
584 return cmds
585 case uiInitialize:
586 return append(cmds, m.updateInitializeView(msg)...)
587 case uiChat, uiLanding, uiChatCompact:
588 switch m.focus {
589 case uiFocusMain:
590 case uiFocusEditor:
591 switch {
592 case key.Matches(msg, m.keyMap.Editor.Newline):
593 m.textarea.InsertRune('\n')
594 }
595
596 ta, cmd := m.textarea.Update(msg)
597 m.textarea = ta
598 cmds = append(cmds, cmd)
599 return cmds
600 }
601 }
602 return cmds
603}
604
605// updateLayoutAndSize updates the layout and sizes of UI components.
606func (m *UI) updateLayoutAndSize() {
607 m.layout = generateLayout(m, m.width, m.height)
608 m.updateSize()
609}
610
611// updateSize updates the sizes of UI components based on the current layout.
612func (m *UI) updateSize() {
613 // Set help width
614 m.help.SetWidth(m.layout.help.Dx())
615
616 // Handle different app states
617 switch m.state {
618 case uiConfigure, uiInitialize:
619 m.renderHeader(false, m.layout.header.Dx())
620
621 case uiLanding:
622 m.renderHeader(false, m.layout.header.Dx())
623 m.textarea.SetWidth(m.layout.editor.Dx())
624 m.textarea.SetHeight(m.layout.editor.Dy())
625
626 case uiChat:
627 m.renderSidebarLogo(m.layout.sidebar.Dx())
628 m.textarea.SetWidth(m.layout.editor.Dx())
629 m.textarea.SetHeight(m.layout.editor.Dy())
630 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
631
632 case uiChatCompact:
633 // TODO: set the width and heigh of the chat component
634 m.renderHeader(true, m.layout.header.Dx())
635 m.textarea.SetWidth(m.layout.editor.Dx())
636 m.textarea.SetHeight(m.layout.editor.Dy())
637 }
638}
639
640// generateLayout calculates the layout rectangles for all UI components based
641// on the current UI state and terminal dimensions.
642func generateLayout(m *UI, w, h int) layout {
643 // The screen area we're working with
644 area := image.Rect(0, 0, w, h)
645
646 // The help height
647 helpHeight := 1
648 // The editor height
649 editorHeight := 5
650 // The sidebar width
651 sidebarWidth := 30
652 // The header height
653 // TODO: handle compact
654 headerHeight := 4
655
656 var helpKeyMap help.KeyMap = m
657 if m.help.ShowAll {
658 for _, row := range helpKeyMap.FullHelp() {
659 helpHeight = max(helpHeight, len(row))
660 }
661 }
662
663 // Add app margins
664 appRect := area
665 appRect.Min.X += 1
666 appRect.Min.Y += 1
667 appRect.Max.X -= 1
668 appRect.Max.Y -= 1
669
670 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
671 // extra padding on left and right for these states
672 appRect.Min.X += 1
673 appRect.Max.X -= 1
674 }
675
676 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
677
678 layout := layout{
679 area: area,
680 help: helpRect,
681 }
682
683 // Handle different app states
684 switch m.state {
685 case uiConfigure, uiInitialize:
686 // Layout
687 //
688 // header
689 // ------
690 // main
691 // ------
692 // help
693
694 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
695 layout.header = headerRect
696 layout.main = mainRect
697
698 case uiLanding:
699 // Layout
700 //
701 // header
702 // ------
703 // main
704 // ------
705 // editor
706 // ------
707 // help
708 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
709 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
710 // Remove extra padding from editor (but keep it for header and main)
711 editorRect.Min.X -= 1
712 editorRect.Max.X += 1
713 layout.header = headerRect
714 layout.main = mainRect
715 layout.editor = editorRect
716
717 case uiChat:
718 // Layout
719 //
720 // ------|---
721 // main |
722 // ------| side
723 // editor|
724 // ----------
725 // help
726
727 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
728 // Add padding left
729 sideRect.Min.X += 1
730 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
731 mainRect.Max.X -= 1 // Add padding right
732 // Add bottom margin to main
733 mainRect.Max.Y -= 1
734 layout.sidebar = sideRect
735 layout.main = mainRect
736 layout.editor = editorRect
737
738 case uiChatCompact:
739 // Layout
740 //
741 // compact-header
742 // ------
743 // main
744 // ------
745 // editor
746 // ------
747 // help
748 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
749 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
750 layout.header = headerRect
751 layout.main = mainRect
752 layout.editor = editorRect
753 }
754
755 if !layout.editor.Empty() {
756 // Add editor margins 1 top and bottom
757 layout.editor.Min.Y += 1
758 layout.editor.Max.Y -= 1
759 }
760
761 return layout
762}
763
764// layout defines the positioning of UI elements.
765type layout struct {
766 // area is the overall available area.
767 area uv.Rectangle
768
769 // header is the header shown in special cases
770 // e.x when the sidebar is collapsed
771 // or when in the landing page
772 // or in init/config
773 header uv.Rectangle
774
775 // main is the area for the main pane. (e.x chat, configure, landing)
776 main uv.Rectangle
777
778 // editor is the area for the editor pane.
779 editor uv.Rectangle
780
781 // sidebar is the area for the sidebar.
782 sidebar uv.Rectangle
783
784 // help is the area for the help view.
785 help uv.Rectangle
786}
787
788// setEditorPrompt configures the textarea prompt function based on whether
789// yolo mode is enabled.
790func (m *UI) setEditorPrompt() {
791 if m.com.App.Permissions.SkipRequests() {
792 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
793 return
794 }
795 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
796}
797
798// normalPromptFunc returns the normal editor prompt style (" > " on first
799// line, "::: " on subsequent lines).
800func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
801 t := m.com.Styles
802 if info.LineNumber == 0 {
803 return " > "
804 }
805 if info.Focused {
806 return t.EditorPromptNormalFocused.Render()
807 }
808 return t.EditorPromptNormalBlurred.Render()
809}
810
811// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
812// and colored dots.
813func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
814 t := m.com.Styles
815 if info.LineNumber == 0 {
816 if info.Focused {
817 return t.EditorPromptYoloIconFocused.Render()
818 } else {
819 return t.EditorPromptYoloIconBlurred.Render()
820 }
821 }
822 if info.Focused {
823 return t.EditorPromptYoloDotsFocused.Render()
824 }
825 return t.EditorPromptYoloDotsBlurred.Render()
826}
827
828var readyPlaceholders = [...]string{
829 "Ready!",
830 "Ready...",
831 "Ready?",
832 "Ready for instructions",
833}
834
835var workingPlaceholders = [...]string{
836 "Working!",
837 "Working...",
838 "Brrrrr...",
839 "Prrrrrrrr...",
840 "Processing...",
841 "Thinking...",
842}
843
844// randomizePlaceholders selects random placeholder text for the textarea's
845// ready and working states.
846func (m *UI) randomizePlaceholders() {
847 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
848 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
849}
850
851// renderHeader renders and caches the header logo at the specified width.
852func (m *UI) renderHeader(compact bool, width int) {
853 // TODO: handle the compact case differently
854 m.header = renderLogo(m.com.Styles, compact, width)
855}
856
857// renderSidebarLogo renders and caches the sidebar logo at the specified
858// width.
859func (m *UI) renderSidebarLogo(width int) {
860 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
861}
862
863// renderLogo renders the Crush logo with the given styles and dimensions.
864func renderLogo(t *styles.Styles, compact bool, width int) string {
865 return logo.Render(version.Version, compact, logo.Opts{
866 FieldColor: t.LogoFieldColor,
867 TitleColorA: t.LogoTitleColorA,
868 TitleColorB: t.LogoTitleColorB,
869 CharmColor: t.LogoCharmColor,
870 VersionColor: t.LogoVersionColor,
871 Width: width,
872 })
873}