ui.go

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