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	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}