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