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